diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 725820f664e..af4d5651ea0 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -126,6 +126,7 @@ "../src/main/antigravity/hooks-json-bundle.ts", "../src/main/claude/hook-settings.ts", "../src/main/claude/hook-service.ts", + "../src/main/claude/claude-profile-hook-target.ts", "../src/main/claude/statusline-script.ts", "../src/main/claude/windows-hook-files.ts", "../src/main/claude-accounts/keychain.ts", diff --git a/src/main/claude-accounts/claude-profile-history.test.ts b/src/main/claude-accounts/claude-profile-history.test.ts new file mode 100644 index 00000000000..606c316d3a6 --- /dev/null +++ b/src/main/claude-accounts/claude-profile-history.test.ts @@ -0,0 +1,253 @@ +import * as fs from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +vi.mock('electron', () => ({ app: { getPath: () => '/unused-test-path' } })) +vi.mock('node:fs', async (original) => { + const actual = await original() + return { + ...actual, + renameSync: vi.fn(actual.renameSync), + statSync: vi.fn(actual.statSync) + } +}) +import { shareClaudeProfileHistory } from './claude-profile-history' +const roots: string[] = [] +function fixture() { + const root = fs.realpathSync(fs.mkdtempSync(join(tmpdir(), 'claude-profile-history-'))) + roots.push(root) + const profileHome = join(root, 'profile') + const userHome = join(root, 'user') + const defaultHome = join(userHome, '.claude') + fs.mkdirSync(profileHome) + fs.mkdirSync(defaultHome, { recursive: true }) + const share = (platform: NodeJS.Platform = 'linux') => + shareClaudeProfileHistory({ profileHome, userHome, platform }) + const history = (): string => fs.readFileSync(join(defaultHome, 'history.jsonl'), 'utf8') + return { profileHome, userHome, defaultHome, share, history } +} +afterEach(() => { + vi.resetAllMocks() + for (const dir of roots.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }) + } +}) + +// Why: these create real symlinks, which Windows needs privilege for. +const itLinks = it.skipIf(process.platform === 'win32') + +describe('Claude profile history sharing', () => { + itLinks( + 'merges session trees without overwriting conflicts and shares future default writes', + async () => { + const f = fixture() + for (const home of [f.profileHome, f.defaultHome]) { + fs.mkdirSync(join(home, 'projects')) + } + fs.writeFileSync(join(f.profileHome, 'projects/session.jsonl'), 'new') + fs.writeFileSync(join(f.profileHome, 'projects/conflict.jsonl'), 'private') + fs.writeFileSync(join(f.defaultHome, 'projects/conflict.jsonl'), 'existing') + const report = await f.share() + expect(report.surfaces.projects).toBe('linked') + expect(report.warnings).toContainEqual( + expect.objectContaining({ surface: 'projects', code: 'retained-conflict' }) + ) + expect(fs.readFileSync(join(f.defaultHome, 'projects/session.jsonl'), 'utf8')).toBe('new') + expect(fs.readFileSync(join(f.defaultHome, 'projects/conflict.jsonl'), 'utf8')).toBe( + 'existing' + ) + expect( + fs.readFileSync(join(f.profileHome, 'projects.orca-profile-merge/conflict.jsonl'), 'utf8') + ).toBe('private') + fs.writeFileSync(join(f.defaultHome, 'projects/later.jsonl'), 'later') + expect(fs.readFileSync(join(f.profileHome, 'projects/later.jsonl'), 'utf8')).toBe('later') + } + ) + itLinks('recovers a directory swap interrupted before link publication', async () => { + const f = fixture() + fs.mkdirSync(join(f.profileHome, 'projects.orca-profile-merge')) + fs.writeFileSync(join(f.profileHome, 'projects.orca-profile-merge/session.jsonl'), 'saved') + await f.share() + expect(fs.realpathSync(join(f.profileHome, 'projects'))).toBe( + fs.realpathSync(join(f.defaultHome, 'projects')) + ) + expect(fs.readFileSync(join(f.defaultHome, 'projects/session.jsonl'), 'utf8')).toBe('saved') + }) + itLinks('keeps moving entries past one that fails and reports it', async () => { + const f = fixture() + fs.mkdirSync(join(f.profileHome, 'todos')) + for (const name of ['a.json', 'b.json', 'c.json']) { + fs.writeFileSync(join(f.profileHome, 'todos', name), name) + } + const actual = vi.mocked(fs.renameSync).getMockImplementation()! + vi.mocked(fs.renameSync).mockImplementation((from, to) => { + if (String(from).endsWith('b.json')) { + throw Object.assign(new Error('busy'), { code: 'EBUSY' }) + } + actual(from, to) + }) + const report = await f.share() + expect(fs.readdirSync(join(f.defaultHome, 'todos')).sort()).toEqual(['a.json', 'c.json']) + expect(report.warnings).toContainEqual( + expect.objectContaining({ surface: 'todos', code: 'failed' }) + ) + vi.mocked(fs.renameSync).mockReset() + await f.share() + expect(fs.readdirSync(join(f.defaultHome, 'todos')).sort()).toEqual([ + 'a.json', + 'b.json', + 'c.json' + ]) + }) + itLinks('keeps a session tree private across filesystems', async () => { + const f = fixture() + fs.mkdirSync(join(f.profileHome, 'plans')) + fs.writeFileSync(join(f.profileHome, 'plans/p.md'), 'plan') + const actual = vi.mocked(fs.statSync).getMockImplementation()! + vi.mocked(fs.statSync).mockImplementation((file, options) => { + const stats = actual(file, options) + return file === join(f.profileHome, 'plans') && stats + ? Object.assign(stats, { dev: -1 }) + : stats + }) + const report = await f.share() + expect(report.warnings).toContainEqual( + expect.objectContaining({ surface: 'plans', code: 'cross-filesystem' }) + ) + expect(fs.lstatSync(join(f.profileHome, 'plans')).isDirectory()).toBe(true) + expect(fs.readFileSync(join(f.profileHome, 'plans/p.md'), 'utf8')).toBe('plan') + }) + itLinks('retains prompt cursors, drains late appends and repairs a CLI replacement', async () => { + const f = fixture() + fs.writeFileSync(join(f.defaultHome, 'history.jsonl'), 'default') + fs.writeFileSync(join(f.profileHome, 'history.jsonl'), 'profile\n') + await f.share() + const pending = join(f.profileHome, 'history.jsonl.orca-profile-merge') + fs.appendFileSync(pending, 'late\n') + await f.share() + await f.share() + expect(f.history()).toBe('default\nprofile\nlate\n') + fs.writeFileSync(join(f.profileHome, 'replacement'), 'replacement\n') + fs.renameSync(join(f.profileHome, 'replacement'), join(f.profileHome, 'history.jsonl')) + await f.share() + expect(fs.realpathSync(join(f.profileHome, 'history.jsonl'))).toBe( + fs.realpathSync(join(f.defaultHome, 'history.jsonl')) + ) + expect(f.history()).toBe('default\nprofile\nlate\nreplacement\n') + }) + itLinks('terminates merged records so the next append starts its own line', async () => { + const f = fixture() + fs.writeFileSync(join(f.defaultHome, 'history.jsonl'), 'd1\n') + fs.writeFileSync(join(f.profileHome, 'history.jsonl'), 'p1\np2') + await f.share() + fs.appendFileSync(join(f.profileHome, 'history.jsonl'), 'after-link\n') + expect(f.history()).toBe('d1\np1\np2\nafter-link\n') + }) + itLinks('adds only the new lines of a CLI rewrite of the shared file', async () => { + const f = fixture() + fs.writeFileSync(join(f.defaultHome, 'history.jsonl'), 'd1\nd2\n') + await f.share() + fs.writeFileSync(join(f.profileHome, 'rewrite'), 'd1\nd2\nnew\n') + fs.renameSync(join(f.profileHome, 'rewrite'), join(f.profileHome, 'history.jsonl')) + await f.share() + expect(f.history()).toBe('d1\nd2\nnew\n') + }) + itLinks( + 'does not re-append lines after a purge drops one the shared file still has', + async () => { + const f = fixture() + fs.writeFileSync(join(f.defaultHome, 'history.jsonl'), 'a\nb\nc\n') + await f.share() + fs.writeFileSync(join(f.profileHome, 'rewrite'), 'a\nc\n') + fs.renameSync(join(f.profileHome, 'rewrite'), join(f.profileHome, 'history.jsonl')) + await f.share() + expect(f.history()).toBe('a\nb\nc\n') + } + ) + itLinks('deletes a retained copy once a later run finds nothing new in it', async () => { + const f = fixture() + fs.writeFileSync(join(f.profileHome, 'history.jsonl'), 'p1\n') + await f.share() + const pending = join(f.profileHome, 'history.jsonl.orca-profile-merge') + expect(fs.existsSync(`${pending}.offset`)).toBe(true) + fs.appendFileSync(pending, 'late\n') + await f.share() + expect(fs.existsSync(pending)).toBe(true) + await f.share() + expect(fs.existsSync(pending)).toBe(false) + expect(fs.existsSync(`${pending}.offset`)).toBe(false) + expect(f.history()).toBe('p1\nlate\n') + }) + itLinks('drains retained generations in numeric order', async () => { + const f = fixture() + for (const generation of [0, 1, 2, 10, 11]) { + const suffix = generation === 0 ? '' : `-${generation}` + fs.writeFileSync( + join(f.profileHome, `history.jsonl.orca-profile-merge${suffix}`), + `g${generation}\n` + ) + } + await f.share() + expect(f.history()).toBe('g0\ng1\ng2\ng10\ng11\n') + }) + itLinks( + 'drains a copy left by an interrupted share without replaying the shared history', + async () => { + const f = fixture() + fs.writeFileSync(join(f.defaultHome, 'history.jsonl'), 'd1\nd2\n') + fs.writeFileSync(join(f.profileHome, 'history.jsonl.orca-profile-merge'), 'd1\nd2\nnew\n') + await f.share() + expect(f.history()).toBe('d1\nd2\nnew\n') + } + ) + itLinks('still links a session tree when its old leftover cannot be read', async () => { + const f = fixture() + const leftover = join(f.profileHome, 'projects.orca-profile-merge') + fs.mkdirSync(leftover) + fs.writeFileSync(join(leftover, 's.jsonl'), 'x') + fs.chmodSync(leftover, 0) + const report = await f.share() + fs.chmodSync(leftover, 0o700) + expect(report.surfaces.projects).toBe('linked') + expect(report.warnings).toContainEqual(expect.objectContaining({ surface: 'projects' })) + expect(fs.realpathSync(join(f.profileHome, 'projects'))).toBe( + fs.realpathSync(join(f.defaultHome, 'projects')) + ) + }) + itLinks('still links the profile when an old retained copy cannot be read', async () => { + const f = fixture() + const old = join(f.profileHome, 'history.jsonl.orca-profile-merge') + fs.writeFileSync(old, 'old\n') + fs.chmodSync(old, 0) + fs.writeFileSync(join(f.profileHome, 'history.jsonl'), 'private\n') + const report = await f.share() + fs.chmodSync(old, 0o600) + expect(report.warnings).toContainEqual(expect.objectContaining({ surface: 'history.jsonl' })) + expect(fs.lstatSync(join(f.profileHome, 'history.jsonl')).isSymbolicLink()).toBe(true) + expect(f.history()).toBe('private\n') + }) + it('keeps every profile history private on Windows', async () => { + const f = fixture() + fs.mkdirSync(join(f.profileHome, 'projects')) + fs.writeFileSync(join(f.profileHome, 'history.jsonl'), 'private\n') + const report = await f.share('win32') + expect(report).toEqual({ surfaces: {}, warnings: [] }) + expect(fs.lstatSync(join(f.profileHome, 'projects')).isDirectory()).toBe(true) + expect(fs.readFileSync(join(f.profileHome, 'history.jsonl'), 'utf8')).toBe('private\n') + expect(fs.readdirSync(f.defaultHome)).toEqual([]) + }) + itLinks("pools into the user's own CLAUDE_CONFIG_DIR when they set one", async () => { + const f = fixture() + const userConfigDir = join(f.userHome, 'custom-claude') + fs.writeFileSync(join(f.profileHome, 'history.jsonl'), 'p1\n') + await shareClaudeProfileHistory({ + profileHome: f.profileHome, + userHome: f.userHome, + userConfigDir, + platform: 'linux' + }) + expect(fs.realpathSync(join(f.profileHome, 'projects'))).toBe(join(userConfigDir, 'projects')) + expect(fs.readFileSync(join(userConfigDir, 'history.jsonl'), 'utf8')).toBe('p1\n') + expect(fs.readdirSync(f.defaultHome)).toEqual([]) + }) +}) diff --git a/src/main/claude-accounts/claude-profile-history.ts b/src/main/claude-accounts/claude-profile-history.ts new file mode 100644 index 00000000000..06c5f0f90f5 --- /dev/null +++ b/src/main/claude-accounts/claude-profile-history.ts @@ -0,0 +1,177 @@ +import { + mkdirSync, + readdirSync, + realpathSync, + renameSync, + rmdirSync, + statSync, + symlinkSync +} from 'node:fs' +import { join } from 'node:path' +import { resolveClaudeDefaultHome } from './claude-profile-paths' +import { + ClaudeProfileSurfaceError, + createClaudeProfileReport, + runClaudeProfileSurface, + warnClaudeProfile, + type ClaudeProfileReport, + type ClaudeProfileSurfaceOutcome +} from './claude-profile-report' +import { + CLAUDE_PROFILE_MERGE_SUFFIX, + lstatIfPresent, + mergeClaudeProfilePromptHistory +} from './claude-profile-prompt-history' + +export const CLAUDE_PROFILE_HISTORY_DIRS = [ + 'projects', + 'sessions', + 'session-env', + 'file-history', + 'shell-snapshots', + 'todos', + 'paste-cache', + 'tasks', + 'plans', + 'transcripts' +] as const + +type MoveResult = { retained: number; failed: unknown[] } + +/** Keeps going past a failed entry so one locked file never hides the rest. */ +function moveHistoryTree(source: string, destination: string, result: MoveResult): void { + for (const item of readdirSync(source, { withFileTypes: true })) { + const from = join(source, item.name) + const to = join(destination, item.name) + try { + const existing = lstatIfPresent(to) + if (!existing) { + renameSync(from, to) + } else if (item.isDirectory() && existing.isDirectory()) { + moveHistoryTree(from, to, result) + } else { + result.retained += 1 + } + } catch (error) { + result.failed.push(error) + } + } + if (readdirSync(source).length === 0) { + rmdirSync(source) + } +} + +function drainDirectory( + pending: string, + destination: string, + report: ClaudeProfileReport, + name: (typeof CLAUDE_PROFILE_HISTORY_DIRS)[number] +): void { + const result: MoveResult = { retained: 0, failed: [] } + moveHistoryTree(pending, destination, result) + if (result.failed.length > 0) { + warnClaudeProfile(report, name, result.failed[0]) + } + if (result.retained > 0) { + const detail = `${result.retained} conflicting entries kept in ${pending}` + warnClaudeProfile(report, name, new ClaudeProfileSurfaceError('retained-conflict', detail)) + } +} + +function crossFilesystem(): ClaudeProfileSurfaceError { + return new ClaudeProfileSurfaceError( + 'cross-filesystem', + 'History stays private across filesystems' + ) +} + +function mergeDirectory( + profile: string, + home: string, + name: (typeof CLAUDE_PROFILE_HISTORY_DIRS)[number], + report: ClaudeProfileReport +): ClaudeProfileSurfaceOutcome { + const source = join(profile, name) + const destination = join(home, name) + const pending = `${source}${CLAUDE_PROFILE_MERGE_SUFFIX}` + mkdirSync(destination, { recursive: true }) + const sameDevice = (file: string): boolean => statSync(file).dev === statSync(destination).dev + if (lstatIfPresent(pending)?.isDirectory()) { + if (!sameDevice(pending)) { + if (!lstatIfPresent(source)) { + renameSync(pending, source) + } + throw crossFilesystem() + } + try { + drainDirectory(pending, destination, report, name) + } catch (error) { + // Why: an unreadable leftover is reported and kept; it must not stop the share itself. + warnClaudeProfile(report, name, error) + } + } + const current = lstatIfPresent(source) + if (current?.isSymbolicLink()) { + return realpathSync(source) === realpathSync(destination) ? 'unchanged' : 'user-owned' + } + if (current && !current.isDirectory()) { + return 'user-owned' + } + if (current) { + if (!sameDevice(source)) { + throw crossFilesystem() + } + if (lstatIfPresent(pending)) { + throw new ClaudeProfileSurfaceError( + 'retained-conflict', + `Earlier conflicts kept in ${pending}` + ) + } + renameSync(source, pending) + } + try { + symlinkSync(destination, source) + } catch (error) { + if (current) { + if (!lstatIfPresent(source)) { + renameSync(pending, source) + } else if (lstatIfPresent(source)?.isDirectory()) { + moveHistoryTree(pending, source, { retained: 0, failed: [] }) + } + } + throw new ClaudeProfileSurfaceError('link-failed', String(error)) + } + if (current) { + drainDirectory(pending, destination, report, name) + } + return 'linked' +} + +/** + * Pools a profile's sessions and prompt history into the default home. Execution-host paths only; + * callers go through provisionClaudeAccountProfile, which gates and creates the profile. + * Windows keeps each profile's history private: its links are junctions and hardlinks. + */ +export async function shareClaudeProfileHistory(args: { + profileHome: string + userHome: string + /** The user's own CLAUDE_CONFIG_DIR; `~/.claude` when unset. */ + userConfigDir?: string + platform?: NodeJS.Platform +}): Promise { + const report = createClaudeProfileReport() + if ((args.platform ?? process.platform) === 'win32') { + return report + } + const defaultHome = resolveClaudeDefaultHome(args.userHome, args.userConfigDir) + mkdirSync(defaultHome, { recursive: true, mode: 0o700 }) + for (const name of CLAUDE_PROFILE_HISTORY_DIRS) { + await runClaudeProfileSurface(report, name, () => + mergeDirectory(args.profileHome, defaultHome, name, report) + ) + } + await runClaudeProfileSurface(report, 'history.jsonl', () => + mergeClaudeProfilePromptHistory(args.profileHome, defaultHome, report) + ) + return report +} diff --git a/src/main/claude-accounts/claude-profile-paths.test.ts b/src/main/claude-accounts/claude-profile-paths.test.ts new file mode 100644 index 00000000000..775e4193150 --- /dev/null +++ b/src/main/claude-accounts/claude-profile-paths.test.ts @@ -0,0 +1,171 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +vi.mock('electron', () => ({ app: { getPath: () => '/unused-test-path' } })) +import { + assertOutsideDefaultClaudeHomes, + describeClaudeProfile, + prepareClaudeProfileDirectory, + readClaudeProfileObject, + readUserClaudeConfigDir +} from './claude-profile-paths' + +// Case-only aliases exist only on a case-insensitive filesystem (default APFS, NTFS). +const caseInsensitive = (() => { + const dir = mkdtempSync(join(tmpdir(), 'claude-case-probe-')) + try { + mkdirSync(join(dir, 'probe')) + return existsSync(join(dir, 'PROBE')) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +})() +const roots: string[] = [] +function root(): string { + const dir = mkdtempSync(join(tmpdir(), 'claude-profile-paths-')) + roots.push(dir) + return dir +} +afterEach(() => { + for (const dir of roots.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) +const local = { runtime: 'host', executionHostId: 'local' } as const + +describe('Claude profile namespace', () => { + it('binds the new namespace to an account and execution target without touching legacy auth', () => { + const dir = root() + const userHome = root() + const target = { executionHostId: 'runtime:env-1', runtime: 'wsl', distro: 'Ubuntu' } as const + const profile = describeClaudeProfile(dir, 'account-a', target) + expect(profile).toEqual({ + version: 1, + accountId: 'account-a', + target, + home: join(dir, 'claude-profiles/account-a/home') + }) + prepareClaudeProfileDirectory(dir, profile, userHome) + expect( + JSON.parse(readFileSync(join(dir, 'claude-profiles/account-a/profile.json'), 'utf8')) + ).toEqual({ version: 1, accountId: 'account-a', runtime: 'wsl', distro: 'Ubuntu' }) + // The host id is the caller's view of the host, so another caller's spelling is the same profile. + prepareClaudeProfileDirectory( + dir, + { ...profile, target: { distro: 'Ubuntu', runtime: 'wsl', executionHostId: 'local' } }, + userHome + ) + expect(() => + prepareClaudeProfileDirectory( + dir, + { ...profile, home: join(dir, 'claude-accounts/account-a/auth') }, + userHome + ) + ).toThrow() + // One spelling everywhere: Claude names the profile's Keychain entry from the exact text. + expect(describeClaudeProfile(`${dir}/./x/../`, 'account-a', target).home).toBe(profile.home) + expect(() => describeClaudeProfile(dir, '../escape', target)).toThrow() + expect(() => describeClaudeProfile('C:\\orca', 'a', target)).toThrow() + }) + it('refuses a profile whose marker names another account or target, creating nothing', () => { + const dir = root() + const userHome = root() + mkdirSync(join(dir, 'claude-profiles/a'), { recursive: true }) + const marker = join(dir, 'claude-profiles/a/profile.json') + for (const other of [ + { version: 1, accountId: 'b', runtime: 'host' }, + { version: 1, accountId: 'a', runtime: 'wsl', distro: 'Ubuntu' } + ]) { + writeFileSync(marker, JSON.stringify(other)) + expect(() => + prepareClaudeProfileDirectory(dir, describeClaudeProfile(dir, 'a', local), userHome) + ).toThrow('another account') + } + writeFileSync(marker, '{') + expect(() => + prepareClaudeProfileDirectory(dir, describeClaudeProfile(dir, 'a', local), userHome) + ).toThrow('unreadable') + expect(existsSync(join(dir, 'claude-profiles/a/home'))).toBe(false) + expect(readFileSync(marker, 'utf8')).toBe('{') + }) + it('rejects a linked account parent before creating a profile outside the namespace', () => { + const dir = root() + mkdirSync(join(dir, 'claude-profiles')) + const outside = root() + symlinkSync(outside, join(dir, 'claude-profiles/a'), 'junction') + expect(() => + prepareClaudeProfileDirectory(dir, describeClaudeProfile(dir, 'a', local), root()) + ).toThrow('link') + }) + it('refuses a data root inside the default Claude home', () => { + const userHome = root() + const dataRoot = join(userHome, '.claude', 'orca') + expect(() => + prepareClaudeProfileDirectory(dataRoot, describeClaudeProfile(dataRoot, 'a', local), userHome) + ).toThrow('separate directories') + expect(existsSync(join(userHome, '.claude'))).toBe(false) + }) + it.runIf(caseInsensitive)('refuses a case-only alias of the default home', () => { + const userHome = root() + mkdirSync(join(userHome, '.claude')) + expect(() => assertOutsideDefaultClaudeHomes(join(userHome, '.CLAUDE'), userHome)).toThrow( + 'separate directories' + ) + expect(() => + assertOutsideDefaultClaudeHomes(join(userHome, '.CLAUDE', 'nested'), userHome) + ).toThrow('separate directories') + }) + it("reads the user's own CLAUDE_CONFIG_DIR but never Orca's injected one", () => { + expect(readUserClaudeConfigDir({})).toBeUndefined() + expect(readUserClaudeConfigDir({ CLAUDE_CONFIG_DIR: ' ' })).toBeUndefined() + expect(readUserClaudeConfigDir({ CLAUDE_CONFIG_DIR: '/cfg/claude/' })).toBe( + resolve('/cfg/claude') + ) + expect( + readUserClaudeConfigDir({ + CLAUDE_CONFIG_DIR: '/data/claude-profiles/a/home', + ORCA_CLAUDE_INJECTED_CONFIG_DIR: '/data/claude-profiles/a/home' + }) + ).toBeUndefined() + expect( + readUserClaudeConfigDir({ + CLAUDE_CONFIG_DIR: '/cfg/claude', + ORCA_CLAUDE_INJECTED_CONFIG_DIR: '/data/claude-profiles/a/home' + }) + ).toBe(resolve('/cfg/claude')) + }) + it("refuses a profile at or around the user's own CLAUDE_CONFIG_DIR", () => { + const userHome = root() + const dataRoot = root() + const profile = describeClaudeProfile(dataRoot, 'a', local) + for (const userConfigDir of [profile.home, dataRoot]) { + expect(() => + prepareClaudeProfileDirectory(dataRoot, profile, userHome, userConfigDir) + ).toThrow('separate directories') + } + expect(existsSync(profile.home)).toBe(false) + }) + it('distinguishes missing from empty, malformed, nonobject and inaccessible JSON', () => { + const file = join(root(), 'state.json') + expect(readClaudeProfileObject(file).kind).toBe('absent') + for (const value of ['', '{', '[]', 'null']) { + writeFileSync(file, value) + expect(readClaudeProfileObject(file).kind).toBe('unavailable') + } + // ENOTDIR is a definitive absence, as everywhere else in Orca. + expect(readClaudeProfileObject(join(file, 'child')).kind).toBe('absent') + mkdirSync(join(file, '..', 'dir.json')) + expect(readClaudeProfileObject(join(file, '..', 'dir.json')).kind).toBe('unavailable') + writeFileSync(file, '{"theme":"dark"}') + expect(readClaudeProfileObject(file)).toEqual({ kind: 'present', value: { theme: 'dark' } }) + }) +}) diff --git a/src/main/claude-accounts/claude-profile-paths.ts b/src/main/claude-accounts/claude-profile-paths.ts new file mode 100644 index 00000000000..67010c50ab3 --- /dev/null +++ b/src/main/claude-accounts/claude-profile-paths.ts @@ -0,0 +1,226 @@ +import { lstatSync, mkdirSync, readFileSync, realpathSync } from 'node:fs' +import * as hostPath from 'node:path' +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' +import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' +import type { ExecutionHostId } from '../../shared/execution-host' +import { writeFileAtomically } from '../codex-accounts/fs-utils' +import { ClaudeProfileSurfaceError } from './claude-profile-report' + +/** `executionHostId` routes calls to the host; it is the caller's view, so it is never persisted. */ +export type ClaudeProfileTarget = + | { executionHostId: ExecutionHostId; runtime: 'host' } + | { executionHostId: ExecutionHostId; runtime: 'wsl'; distro: string } + +export type ClaudeProfileDescriptor = { + version: 1 + accountId: string + target: ClaudeProfileTarget + home: string +} + +export type ClaudeProfileRead = + | { kind: 'present'; value: T } + | { kind: 'absent' } + | { kind: 'unavailable'; error: unknown } + +function isProfileObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export function readClaudeProfileObject(file: string): ClaudeProfileRead> { + try { + const value: unknown = JSON.parse(readFileSync(file, 'utf8')) + if (!isProfileObject(value)) { + throw new Error('Expected a profile JSON object') + } + return { kind: 'present', value } + } catch (error) { + return isDefinitiveAbsence(error) ? { kind: 'absent' } : { kind: 'unavailable', error } + } +} + +/** + * dataRoot belongs to the execution host: for WSL it is the guest's Linux data root. `home` is the + * one spelling (absolute, normalized, no trailing separator) sign-in and launch must pass as + * CLAUDE_CONFIG_DIR: Claude names the profile's Keychain entry from that exact text. + */ +export function describeClaudeProfile( + dataRoot: string, + accountId: string, + target: ClaudeProfileTarget +): ClaudeProfileDescriptor { + const path = target.runtime === 'wsl' ? hostPath.posix : hostPath + if (!path.isAbsolute(dataRoot) || !/^[a-zA-Z0-9_-]+$/.test(accountId)) { + throw new Error('Invalid Claude profile location') + } + if (!target.executionHostId || (target.runtime === 'wsl' && !target.distro)) { + throw new Error('Claude profile requires an execution target') + } + return { + version: 1, + accountId, + target, + home: path.join(dataRoot, 'claude-profiles', accountId, 'home') + } +} + +// Host-local facts only, in a fixed key order: the marker sits on the execution host's own disk. +function ownershipRecord( + version: unknown, + accountId: unknown, + runtime: unknown, + distro: unknown +): string { + return JSON.stringify({ version, accountId, runtime, distro }) +} + +function readOwnershipMarker(file: string): string | null { + try { + if (!lstatSync(file).isFile()) { + throw new ClaudeProfileSurfaceError('invalid-profile', 'Claude profile marker is not a file') + } + } catch (error) { + if (isDefinitiveAbsence(error)) { + return null + } + throw error + } + const marker = readClaudeProfileObject(file) + if (marker.kind !== 'present') { + throw new ClaudeProfileSurfaceError('unreadable', 'Claude profile marker is unreadable') + } + const { version, accountId, runtime, distro } = marker.value + return ownershipRecord(version, accountId, runtime, distro) +} + +/** + * The only gate before writing into a profile: namespace, containment, no linked components, + * outside Claude's default homes, and an ownership marker beside the home. Refuses before creating anything. + */ +export function prepareClaudeProfileDirectory( + dataRoot: string, + profile: ClaudeProfileDescriptor, + userHome: string, + userConfigDir?: string +): void { + let expected: ClaudeProfileDescriptor + try { + expected = describeClaudeProfile(dataRoot, profile.accountId, profile.target) + } catch (error) { + throw new ClaudeProfileSurfaceError('invalid-profile', String(error)) + } + if (profile.version !== 1 || profile.home !== expected.home) { + throw new ClaudeProfileSurfaceError( + 'invalid-profile', + 'Claude profile does not match its account namespace' + ) + } + assertClaudeProfileDescendant(dataRoot, profile.home) + assertOutsideDefaultClaudeHomes(profile.home, userHome, userConfigDir) + const markerPath = join(dirname(profile.home), 'profile.json') + const distro = profile.target.runtime === 'wsl' ? profile.target.distro : undefined + const record = ownershipRecord(profile.version, profile.accountId, profile.target.runtime, distro) + const marker = readOwnershipMarker(markerPath) + if (marker !== null && marker !== record) { + throw new ClaudeProfileSurfaceError( + 'invalid-profile', + 'Claude profile belongs to another account or target' + ) + } + mkdirSync(profile.home, { recursive: true, mode: 0o700 }) + if (marker === null) { + writeFileAtomically(markerPath, `${record}\n`, { mode: 0o600 }) + } +} + +export function assertClaudeProfileDescendant(root: string, destination: string): void { + const suffix = relative(resolve(root), resolve(destination)) + if (!suffix || suffix === '..' || suffix.startsWith(`..${sep}`) || isAbsolute(suffix)) { + throw new ClaudeProfileSurfaceError( + 'invalid-profile', + 'Claude profile destination escapes its root' + ) + } + // The caller owns root; links below it must not redirect profile writes. + let cursor = resolve(root) + for (const part of suffix.split(sep)) { + cursor = join(cursor, part) + try { + if (lstatSync(cursor).isSymbolicLink()) { + throw new ClaudeProfileSurfaceError( + 'invalid-profile', + 'Claude profile path contains a link' + ) + } + } catch (error) { + if (!isDefinitiveAbsence(error)) { + throw error + } + } + } +} + +/** + * Resolves links in the deepest existing ancestor so a not-yet-created path cannot hide behind one; + * the native call also returns on-disk case, so a case-only alias compares equal. + */ +function canonicalPath(file: string): string { + const resolved = resolve(file) + try { + return realpathSync.native(resolved) + } catch (error) { + if (!isDefinitiveAbsence(error)) { + throw error + } + const parent = dirname(resolved) + return parent === resolved ? resolved : join(canonicalPath(parent), basename(resolved)) + } +} + +export function assertDistinctClaudeProfile(profile: string, defaultHome: string): void { + const left = canonicalPath(profile) + const right = canonicalPath(defaultHome) + for (const [root, destination] of [ + [left, right], + [right, left] + ] as const) { + const suffix = relative(root, destination) + if (!suffix || (!suffix.startsWith(`..${sep}`) && suffix !== '..' && !isAbsolute(suffix))) { + throw new ClaudeProfileSurfaceError( + 'invalid-profile', + 'Claude profile and default home must be separate directories' + ) + } + } +} + +/** Claude's default homes: a profile may never be, contain, or sit inside one. */ +export function assertOutsideDefaultClaudeHomes( + profileHome: string, + userHome: string, + userConfigDir?: string +): void { + assertDistinctClaudeProfile(profileHome, join(userHome, '.claude')) + assertDistinctClaudeProfile(profileHome, join(userHome, '.config', 'claude')) + if (userConfigDir !== undefined) { + assertDistinctClaudeProfile(profileHome, userConfigDir) + } +} + +/** Set beside every CLAUDE_CONFIG_DIR Orca injects, so its own value never reads as the user's. */ +export const CLAUDE_INJECTED_CONFIG_DIR_ENV = 'ORCA_CLAUDE_INJECTED_CONFIG_DIR' + +/** The user's own CLAUDE_CONFIG_DIR (their System default), or undefined for `~/.claude`. */ +export function readUserClaudeConfigDir(env: NodeJS.ProcessEnv): string | undefined { + const configDir = env.CLAUDE_CONFIG_DIR?.trim() + const injected = env[CLAUDE_INJECTED_CONFIG_DIR_ENV]?.trim() + if (!configDir || (injected && resolve(injected) === resolve(configDir))) { + return undefined + } + return resolve(configDir) +} + +/** The folder profiles share from: the user's own CLAUDE_CONFIG_DIR, else `~/.claude`. */ +export function resolveClaudeDefaultHome(userHome: string, userConfigDir?: string): string { + return userConfigDir ?? join(userHome, '.claude') +} diff --git a/src/main/claude-accounts/claude-profile-prompt-history.ts b/src/main/claude-accounts/claude-profile-prompt-history.ts new file mode 100644 index 00000000000..b11cfeb93c6 --- /dev/null +++ b/src/main/claude-accounts/claude-profile-prompt-history.ts @@ -0,0 +1,171 @@ +import { + appendFileSync, + closeSync, + lstatSync, + openSync, + readFileSync, + readdirSync, + readSync, + realpathSync, + renameSync, + statSync, + symlinkSync, + unlinkSync, + writeFileSync, + type Stats +} from 'node:fs' +import { join } from 'node:path' +import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' +import { + ClaudeProfileSurfaceError, + warnClaudeProfile, + type ClaudeProfileReport, + type ClaudeProfileSurfaceOutcome +} from './claude-profile-report' + +export const CLAUDE_PROFILE_MERGE_SUFFIX = '.orca-profile-merge' +const HISTORY = 'history.jsonl' +const PENDING = `${HISTORY}${CLAUDE_PROFILE_MERGE_SUFFIX}` + +export function lstatIfPresent(file: string): Stats | undefined { + try { + return lstatSync(file) + } catch (error) { + if (!isDefinitiveAbsence(error)) { + throw error + } + return undefined + } +} + +function appendHistory(destination: string, lines: string[]): void { + if (lines.length === 0) { + return + } + const size = statSync(destination).size + let separator = false + if (size > 0) { + const fd = openSync(destination, 'r') + try { + const tail = Buffer.alloc(1) + readSync(fd, tail, 0, 1, size - 1) + separator = tail[0] !== 10 + } finally { + closeSync(fd) + } + } + // Why: every record ends its line so it cannot fuse with Claude's next append. + appendFileSync(destination, `${separator ? '\n' : ''}${lines.join('\n')}\n`) +} + +/** + * Keeps the renamed file and its cursor: a Claude that opened the old path just before the swap + * appends there, and the next run drains it; a later run that finds nothing new deletes it. + */ +function drainHistory(pending: string, destination: string, shared: Set): void { + const content = readFileSync(pending) + const cursor = `${pending}.offset` + let offset = 0 + let saved = false + try { + const stored = Number(readFileSync(cursor, 'utf8')) + if (Number.isSafeInteger(stored) && stored >= 0 && stored <= content.length) { + offset = stored + saved = true + } + } catch (error) { + if (!isDefinitiveAbsence(error)) { + throw error + } + } + if (saved && offset === content.length) { + // Why: cursor first; a copy without one re-drains to nothing because its lines are present. + unlinkSync(cursor) + unlinkSync(pending) + return + } + // Why: `claude purge` rewrites the shared file through the link; only lines it lacks are new. + const lines = content + .subarray(offset) + .toString('utf8') + .split('\n') + .filter((line) => line !== '' && !shared.has(line)) + appendHistory(destination, lines) + for (const line of lines) { + shared.add(line) + } + // Advance only after append; a crash re-drains, and lines already present are skipped. + writeFileSync(cursor, `${content.length}\n`, { mode: 0o600 }) +} + +function pendingGeneration(name: string): number | null { + if (name === PENDING) { + return 0 + } + const suffix = name.slice(PENDING.length + 1) + return name.startsWith(`${PENDING}-`) && /^\d+$/.test(suffix) ? Number(suffix) : null +} + +function nextFreePath(base: string): string { + for (let generation = 0; ; generation++) { + const candidate = generation === 0 ? base : `${base}-${generation}` + if (!lstatIfPresent(candidate)) { + return candidate + } + } +} + +/** POSIX only: links the profile's `history.jsonl` to the shared one and appends its own lines. */ +export function mergeClaudeProfilePromptHistory( + profile: string, + home: string, + report: ClaudeProfileReport +): ClaudeProfileSurfaceOutcome { + const source = join(profile, HISTORY) + const destination = join(home, HISTORY) + if (!lstatIfPresent(destination)) { + writeFileSync(destination, '', { flag: 'wx', mode: 0o600 }) + } + const shared = new Set(readFileSync(destination, 'utf8').split('\n')) + const pendings: { name: string; generation: number }[] = [] + for (const item of readdirSync(profile, { withFileTypes: true })) { + const generation = pendingGeneration(item.name) + if (item.isFile() && generation !== null) { + pendings.push({ name: item.name, generation }) + } + } + // Why: directory order is not creation order; generations keep prompts in the order they were written. + pendings.sort((left, right) => left.generation - right.generation) + for (const { name } of pendings) { + try { + drainHistory(join(profile, name), destination, shared) + } catch (error) { + // Why: an old retained copy is bookkeeping; it must not keep the profile from being linked. + warnClaudeProfile(report, HISTORY, error) + } + } + const current = lstatIfPresent(source) + if (current?.isSymbolicLink()) { + return realpathSync(source) === realpathSync(destination) ? 'unchanged' : 'user-owned' + } + if (current && !current.isFile()) { + return 'user-owned' + } + let aside: string | undefined + if (current) { + aside = nextFreePath(join(profile, PENDING)) + renameSync(source, aside) + } + try { + symlinkSync(destination, source) + } catch (error) { + if (aside && !lstatIfPresent(source)) { + renameSync(aside, source) + } + throw new ClaudeProfileSurfaceError('link-failed', String(error)) + } + if (aside) { + drainHistory(aside, destination, shared) + } + return 'linked' +} diff --git a/src/main/claude-accounts/claude-profile-provisioning.test.ts b/src/main/claude-accounts/claude-profile-provisioning.test.ts new file mode 100644 index 00000000000..66bf0346d90 --- /dev/null +++ b/src/main/claude-accounts/claude-profile-provisioning.test.ts @@ -0,0 +1,425 @@ +import * as fs from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type * as FsUtils from '../codex-accounts/fs-utils' +vi.mock('electron', () => ({ app: { getPath: () => '/unused-test-path' } })) +vi.mock('node:fs', async (original) => { + const actual = await original() + return { ...actual, symlinkSync: vi.fn(actual.symlinkSync) } +}) +vi.mock('../codex-accounts/fs-utils', async (original) => { + const actual = await original() + return { ...actual, writeFileAtomically: vi.fn(actual.writeFileAtomically) } +}) +import { writeFileAtomically } from '../codex-accounts/fs-utils' +import { CLAUDE_PROFILE_MEMORY_IMPORT, provisionClaudeProfile } from './claude-profile-provisioning' + +const USER_HOOK = { matcher: '', hooks: [{ type: 'command', command: 'notify-me' }] } +const ORCA_HOOK = { + matcher: '', + hooks: [{ type: 'command', command: '"$HOME/.orca/agent-hooks/claude-hook.sh"' }] +} +const roots: string[] = [] +function fixture() { + const root = fs.realpathSync(fs.mkdtempSync(join(tmpdir(), 'claude-profile-setup-'))) + roots.push(root) + const userHome = join(root, 'user') + const profileHome = join(root, 'profile') + const source = join(userHome, '.claude') + fs.mkdirSync(source, { recursive: true }) + fs.mkdirSync(profileHome) + const json = (file: string, value: unknown): void => fs.writeFileSync(file, JSON.stringify(value)) + const read = (file: string): Record => JSON.parse(fs.readFileSync(file, 'utf8')) + return { root, userHome, profileHome, source, json, read } +} +const provision = (f: { profileHome: string; userHome: string }, trustKeys?: string[]) => + provisionClaudeProfile({ + profileHome: f.profileHome, + userHome: f.userHome, + trustKeys, + platform: 'linux' + }) +afterEach(() => { + vi.clearAllMocks() + for (const dir of roots.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }) + } +}) + +// Why: these create real symlinks, which Windows needs privilege for. +const itLinks = it.skipIf(process.platform === 'win32') + +describe('dormant Claude profile provisioning', () => { + itLinks('links resources, keeps private directories and sees later global installs', async () => { + const f = fixture() + const linked = [ + 'skills', + 'plugins', + 'commands', + 'output-styles', + 'themes', + 'workflows' + ] as const + for (const name of [...linked, 'agents', 'rules']) { + fs.mkdirSync(join(f.source, name)) + } + for (const name of ['agents', 'rules']) { + fs.mkdirSync(join(f.profileHome, name)) + fs.writeFileSync(join(f.profileHome, name, 'mine.md'), 'private') + } + const report = await provision(f) + expect(report.surfaces.agents).toBe('user-owned') + expect(report.surfaces.rules).toBe('user-owned') + for (const name of linked) { + expect(report.surfaces[name]).toBe('linked') + expect(fs.realpathSync(join(f.profileHome, name))).toBe(fs.realpathSync(join(f.source, name))) + } + fs.writeFileSync(join(f.source, 'skills/new.md'), 'new skill') + expect(fs.readFileSync(join(f.profileHome, 'skills/new.md'), 'utf8')).toBe('new skill') + for (const name of ['agents', 'rules']) { + expect(fs.readFileSync(join(f.profileHome, name, 'mine.md'), 'utf8')).toBe('private') + } + }) + itLinks( + 'shares personal rules, themes and workflows that later appear in the default home', + async () => { + const f = fixture() + await provision(f) + fs.mkdirSync(join(f.source, 'rules')) + fs.writeFileSync(join(f.source, 'rules/style.md'), 'use tabs') + fs.mkdirSync(join(f.source, 'themes')) + fs.writeFileSync(join(f.source, 'themes/dusk.json'), '{"base":"dark"}') + fs.mkdirSync(join(f.source, 'workflows')) + fs.writeFileSync(join(f.source, 'workflows/review.js'), 'export const meta = {}') + const report = await provision(f) + expect(report.surfaces).toMatchObject({ + rules: 'linked', + themes: 'linked', + workflows: 'linked' + }) + expect(fs.readFileSync(join(f.profileHome, 'rules/style.md'), 'utf8')).toBe('use tabs') + expect(fs.readFileSync(join(f.profileHome, 'themes/dusk.json'), 'utf8')).toBe( + '{"base":"dark"}' + ) + expect(fs.readFileSync(join(f.profileHome, 'workflows/review.js'), 'utf8')).toBe( + 'export const meta = {}' + ) + fs.writeFileSync(join(f.profileHome, 'workflows/saved.js'), 'saved in profile') + expect(fs.readFileSync(join(f.source, 'workflows/saved.js'), 'utf8')).toBe('saved in profile') + } + ) + it('copies keybindings, keeps a profile edit and follows the default while unedited', async () => { + const f = fixture() + const source = join(f.source, 'keybindings.json') + const copy = join(f.profileHome, 'keybindings.json') + fs.writeFileSync(source, '{"bindings":[]}') + expect((await provision(f)).surfaces['keybindings.json']).toBe('synced') + expect(fs.lstatSync(copy).isSymbolicLink()).toBe(false) + expect(fs.readFileSync(copy, 'utf8')).toBe('{"bindings":[]}') + fs.writeFileSync(source, '{"bindings":[1]}') + expect((await provision(f)).surfaces['keybindings.json']).toBe('synced') + expect(fs.readFileSync(copy, 'utf8')).toBe('{"bindings":[1]}') + fs.writeFileSync(copy, '{"bindings":["profile"]}') + fs.writeFileSync(source, '{"bindings":[2]}') + expect((await provision(f)).surfaces['keybindings.json']).toBe('user-owned') + expect(fs.readFileSync(copy, 'utf8')).toBe('{"bindings":["profile"]}') + expect(fs.readFileSync(source, 'utf8')).toBe('{"bindings":[2]}') + }) + it('shares future settings keys and hooks but excludes auth; profile edits survive reprovision', async () => { + const f = fixture() + f.json(join(f.source, 'settings.json'), { + futureFeature: true, + model: 'a', + hooks: { Stop: [USER_HOOK, ORCA_HOOK], SessionStart: [ORCA_HOOK] }, + apiKeyHelper: 'secret', + awsAuthRefresh: 'secret', + awsCredentialExport: 'secret', + forceLoginMethod: 'secret', + forceLoginOrgUUID: 'secret', + env: { + ANTHROPIC_API_KEY: 'secret', + ANTHROPIC_AUTH_TOKEN: 'secret', + CLAUDE_CODE_OAUTH_TOKEN: 'secret', + NORMAL: 'yes' + } + }) + fs.writeFileSync(join(f.source, 'CLAUDE.md'), 'source') + await provision(f) + expect(f.read(join(f.profileHome, 'settings.json'))).toEqual({ + futureFeature: true, + model: 'a', + hooks: { Stop: [USER_HOOK, ORCA_HOOK], SessionStart: [ORCA_HOOK] }, + env: { NORMAL: 'yes' } + }) + f.json(join(f.profileHome, 'settings.json'), { + futureFeature: true, + model: 'private', + env: { NORMAL: 'yes' } + }) + fs.writeFileSync(join(f.profileHome, 'CLAUDE.md'), 'private instructions') + f.json(join(f.source, 'settings.json'), { model: 'b', futureFeature: false }) + fs.writeFileSync(join(f.source, 'CLAUDE.md'), 'updated source') + await provision(f) + expect(f.read(join(f.profileHome, 'settings.json'))).toMatchObject({ + model: 'private', + futureFeature: false + }) + expect(fs.readFileSync(join(f.profileHome, 'CLAUDE.md'), 'utf8')).toBe('private instructions') + }) + itLinks( + 're-adds a shared key the profile deleted and leaves a linked settings file alone', + async () => { + const f = fixture() + f.json(join(f.source, 'settings.json'), { model: 'a', theme: 'x' }) + await provision(f) + f.json(join(f.profileHome, 'settings.json'), { theme: 'x' }) + await provision(f) + expect(f.read(join(f.profileHome, 'settings.json'))).toEqual({ model: 'a', theme: 'x' }) + const elsewhere = join(f.root, 'elsewhere.json') + f.json(elsewhere, { mine: true }) + fs.rmSync(join(f.profileHome, 'settings.json')) + fs.symlinkSync(elsewhere, join(f.profileHome, 'settings.json')) + expect((await provision(f)).surfaces['settings.json']).toBe('user-owned') + expect(f.read(elsewhere)).toEqual({ mine: true }) + } + ) + it('removes a key the default dropped unless the profile changed it, and only keys Orca shared', async () => { + const f = fixture() + const settings = join(f.source, 'settings.json') + f.json(settings, { model: 'a', theme: 'x', apiKeyHelper: 'source-secret' }) + await provision(f) + f.json(join(f.profileHome, 'settings.json'), { + ...f.read(join(f.profileHome, 'settings.json')), + theme: 'mine', + local: true, + apiKeyHelper: 'profile-helper' + }) + f.json(settings, { apiKeyHelper: 'source-secret' }) + expect((await provision(f)).surfaces['settings.json']).toBe('merged') + expect(f.read(join(f.profileHome, 'settings.json'))).toEqual({ + theme: 'mine', + local: true, + apiKeyHelper: 'profile-helper' + }) + expect((await provision(f)).surfaces['settings.json']).toBe('unchanged') + f.json(settings, { model: 'b' }) + await provision(f) + fs.rmSync(settings) + await provision(f) + expect(f.read(join(f.profileHome, 'settings.json'))).toEqual({ + theme: 'mine', + local: true, + apiKeyHelper: 'profile-helper' + }) + const ledger = f.read(join(f.profileHome, '.orca-profile.json')) + expect(JSON.stringify(ledger)).not.toContain('apiKeyHelper') + }) + it('removes nothing while the default settings or state are unreadable', async () => { + const f = fixture() + f.json(join(f.source, 'settings.json'), { model: 'a' }) + f.json(join(f.userHome, '.claude.json'), { mcpServers: { a: {} }, theme: 'dark' }) + f.json(join(f.profileHome, '.claude.json'), { userID: 'p' }) + await provision(f) + fs.writeFileSync(join(f.source, 'settings.json'), '{bad') + fs.writeFileSync(join(f.userHome, '.claude.json'), '{bad') + await provision(f) + expect(f.read(join(f.profileHome, 'settings.json'))).toEqual({ model: 'a' }) + expect(f.read(join(f.profileHome, '.claude.json'))).toMatchObject({ + mcpServers: { a: {} }, + theme: 'dark' + }) + f.json(join(f.userHome, '.claude.json'), { theme: 'dark' }) + await provision(f) + expect(f.read(join(f.profileHome, '.claude.json'))).toEqual({ + userID: 'p', + theme: 'dark', + hasCompletedOnboarding: true + }) + }) + it('requires existing state, merges MCP/theme/onboarding/trust and never copies or writes credentials', async () => { + const f = fixture() + fs.writeFileSync(join(f.source, '.credentials.json'), 'SOURCE_CREDENTIAL_BYTES') + f.json(join(f.userHome, '.claude.json'), { + mcpServers: { local: { command: 'example' } }, + theme: 'dark', + oauthAccount: { email: 'source' }, + userID: 'source-id' + }) + await provision(f) + expect(fs.existsSync(join(f.profileHome, '.claude.json'))).toBe(false) + expect(fs.existsSync(join(f.profileHome, '.credentials.json'))).toBe(false) + fs.writeFileSync(join(f.profileHome, '.credentials.json'), 'PROFILE_CREDENTIAL_BYTES') + f.json(join(f.profileHome, '.claude.json'), { + oauthAccount: { email: 'profile' }, + userID: 'profile-id', + projects: { '/work': { allowedTools: ['Read'] } } + }) + await provision(f, ['/work']) + expect(f.read(join(f.profileHome, '.claude.json'))).toEqual({ + oauthAccount: { email: 'profile' }, + userID: 'profile-id', + mcpServers: { local: { command: 'example' } }, + theme: 'dark', + hasCompletedOnboarding: true, + projects: { '/work': { allowedTools: ['Read'], hasTrustDialogAccepted: true } } + }) + expect(fs.readFileSync(join(f.source, '.credentials.json'), 'utf8')).toBe( + 'SOURCE_CREDENTIAL_BYTES' + ) + expect(fs.readFileSync(join(f.profileHome, '.credentials.json'), 'utf8')).toBe( + 'PROFILE_CREDENTIAL_BYTES' + ) + for (const name of fs.readdirSync(f.profileHome)) { + const file = join(f.profileHome, name) + if (fs.lstatSync(file).isFile()) { + expect(fs.readFileSync(file, 'utf8')).not.toContain('SOURCE_CREDENTIAL_BYTES') + } + } + }) + it('still forces onboarding and trust when the personal state is unreadable', async () => { + const f = fixture() + fs.writeFileSync(join(f.userHome, '.claude.json'), '{"theme": "da') + f.json(join(f.profileHome, '.claude.json'), { userID: 'p' }) + const report = await provision(f, ['/work']) + expect(f.read(join(f.profileHome, '.claude.json'))).toEqual({ + userID: 'p', + hasCompletedOnboarding: true, + projects: { '/work': { hasTrustDialogAccepted: true } } + }) + expect(report.warnings).toContainEqual( + expect.objectContaining({ surface: '.claude.json', code: 'unreadable' }) + ) + }) + it('skips only folder trust when the profile projects value is malformed', async () => { + const f = fixture() + f.json(join(f.userHome, '.claude.json'), { theme: 'dark' }) + f.json(join(f.profileHome, '.claude.json'), { userID: 'p', projects: 'bad' }) + const report = await provision(f, ['/work']) + expect(report.surfaces['.claude.json']).toBe('merged') + expect(f.read(join(f.profileHome, '.claude.json'))).toEqual({ + userID: 'p', + projects: 'bad', + theme: 'dark', + hasCompletedOnboarding: true + }) + }) + it('skips the state write while Claude holds its lock and records nothing for it', async () => { + const f = fixture() + f.json(join(f.userHome, '.claude.json'), { theme: 'dark' }) + f.json(join(f.profileHome, '.claude.json'), { userID: 'p' }) + fs.mkdirSync(join(f.profileHome, '.claude.json.lock')) + const report = await provision(f) + expect(report.surfaces['.claude.json']).toBe('failed') + expect(report.warnings).toContainEqual( + expect.objectContaining({ surface: '.claude.json', code: 'locked' }) + ) + expect(f.read(join(f.profileHome, '.claude.json'))).toEqual({ userID: 'p' }) + fs.rmdirSync(join(f.profileHome, '.claude.json.lock')) + expect((await provision(f)).surfaces['.claude.json']).toBe('merged') + expect(f.read(join(f.profileHome, '.claude.json')).theme).toBe('dark') + }) + it('records a shared value only after its write succeeded', async () => { + const f = fixture() + f.json(join(f.source, 'settings.json'), { theme: 'dark' }) + await provision(f) + f.json(join(f.source, 'settings.json'), { theme: 'light' }) + vi.mocked(writeFileAtomically).mockImplementationOnce(() => { + throw Object.assign(new Error('busy'), { code: 'EBUSY' }) + }) + expect((await provision(f)).surfaces['settings.json']).toBe('failed') + expect((await provision(f)).surfaces['settings.json']).toBe('merged') + expect(f.read(join(f.profileHome, 'settings.json')).theme).toBe('light') + }) + itLinks('resets an unreadable ledger instead of blocking every surface', async () => { + const f = fixture() + fs.mkdirSync(join(f.source, 'skills')) + f.json(join(f.source, 'settings.json'), { model: 'a' }) + fs.writeFileSync(join(f.profileHome, '.orca-profile.json'), '') + const report = await provision(f) + expect(report.surfaces.skills).toBe('linked') + expect(report.surfaces['settings.json']).toBe('merged') + expect(report.warnings).toEqual([]) + expect(f.read(join(f.profileHome, '.orca-profile.json')).keys).toEqual({ + 'settings.json': { model: '"a"' } + }) + }) + itLinks( + 'keys shared values by surface, so another spelling of the profile keeps sharing', + async () => { + const f = fixture() + fs.writeFileSync(join(f.source, 'keybindings.json'), 'v1') + f.json(join(f.source, 'settings.json'), { model: 'a' }) + await provision(f) + fs.writeFileSync(join(f.source, 'keybindings.json'), 'v2') + f.json(join(f.source, 'settings.json'), { model: 'b' }) + const aliasRoot = fs.mkdtempSync(join(tmpdir(), 'claude-profile-alias-')) + roots.push(aliasRoot) + const alias = join(aliasRoot, 'link') + fs.symlinkSync(f.root, alias) + const report = await provisionClaudeProfile({ + profileHome: join(alias, 'profile'), + userHome: f.userHome, + platform: 'linux' + }) + expect(report.surfaces['keybindings.json']).toBe('synced') + expect(f.read(join(f.profileHome, 'settings.json')).model).toBe('b') + } + ) + it('imports the personal CLAUDE.md instead of copying it, so Claude loads it once', async () => { + const f = fixture() + await provision(f) + expect(fs.existsSync(join(f.profileHome, 'CLAUDE.md'))).toBe(false) + fs.writeFileSync(join(f.source, 'CLAUDE.md'), 'personal instructions') + expect((await provision(f)).surfaces['CLAUDE.md']).toBe('synced') + expect(fs.readFileSync(join(f.profileHome, 'CLAUDE.md'), 'utf8')).toBe( + CLAUDE_PROFILE_MEMORY_IMPORT + ) + fs.writeFileSync(join(f.source, 'CLAUDE.md'), 'edited personal instructions') + expect((await provision(f)).surfaces['CLAUDE.md']).toBe('unchanged') + }) + itLinks( + "shares from the user's own CLAUDE_CONFIG_DIR, copying its CLAUDE.md and state", + async () => { + const f = fixture() + const userConfigDir = join(f.userHome, 'custom-claude') + fs.mkdirSync(join(userConfigDir, 'skills'), { recursive: true }) + fs.writeFileSync(join(userConfigDir, 'CLAUDE.md'), 'custom instructions') + f.json(join(userConfigDir, 'settings.json'), { model: 'custom' }) + f.json(join(userConfigDir, '.claude.json'), { theme: 'custom' }) + f.json(join(f.userHome, '.claude.json'), { theme: 'home' }) + f.json(join(f.profileHome, '.claude.json'), { userID: 'p' }) + await provisionClaudeProfile({ ...f, userConfigDir, platform: 'linux' }) + expect(fs.realpathSync(join(f.profileHome, 'skills'))).toBe(join(userConfigDir, 'skills')) + expect(fs.readFileSync(join(f.profileHome, 'CLAUDE.md'), 'utf8')).toBe('custom instructions') + expect(f.read(join(f.profileHome, 'settings.json'))).toEqual({ model: 'custom' }) + expect(f.read(join(f.profileHome, '.claude.json')).theme).toBe('custom') + } + ) + itLinks("links to the default home's own entry, not where a user link of it points", async () => { + const f = fixture() + fs.mkdirSync(join(f.root, 'dotfiles-skills')) + fs.symlinkSync(join(f.root, 'dotfiles-skills'), join(f.source, 'skills')) + expect((await provision(f)).surfaces.skills).toBe('linked') + expect(fs.readlinkSync(join(f.profileHome, 'skills'))).toBe(join(f.source, 'skills')) + expect((await provision(f)).surfaces.skills).toBe('unchanged') + }) + it('uses Windows junctions through platform injection (native Windows remains unverified)', async () => { + const f = fixture() + fs.mkdirSync(join(f.source, 'skills')) + await provisionClaudeProfile({ ...f, platform: 'win32' }) + expect(fs.symlinkSync).toHaveBeenCalledWith( + join(f.source, 'skills'), + join(f.profileHome, 'skills'), + 'junction' + ) + }) + it('leaves malformed profile state unchanged', async () => { + const f = fixture() + fs.writeFileSync(join(f.profileHome, '.claude.json'), '{bad') + expect((await provision(f)).warnings).toContainEqual( + expect.objectContaining({ surface: '.claude.json', code: 'unreadable' }) + ) + expect(fs.readFileSync(join(f.profileHome, '.claude.json'), 'utf8')).toBe('{bad') + }) +}) diff --git a/src/main/claude-accounts/claude-profile-provisioning.ts b/src/main/claude-accounts/claude-profile-provisioning.ts new file mode 100644 index 00000000000..752f7fee3f3 --- /dev/null +++ b/src/main/claude-accounts/claude-profile-provisioning.ts @@ -0,0 +1,225 @@ +import { existsSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { writeFileAtomically } from '../codex-accounts/fs-utils' +import { + applyClaudeFolderTrust, + resolveClaudeGlobalConfigFile, + updateClaudeGlobalConfig +} from '../claude/claude-folder-trust-file' +import { readClaudeProfileObject, resolveClaudeDefaultHome } from './claude-profile-paths' +import { lstatIfPresent } from './claude-profile-prompt-history' +import { + ClaudeProfileSurfaceError, + createClaudeProfileReport, + runClaudeProfileSurface, + warnClaudeProfile, + type ClaudeProfileReport, + type ClaudeProfileSurfaceOutcome +} from './claude-profile-report' +import { + claudeProfileLedgerPath, + dropClaudeProfileKeys, + linkClaudeProfileDirectory, + mergeClaudeProfileKeys, + readClaudeProfileLedger, + syncClaudeProfileFile, + writeClaudeProfileLedger, + type ClaudeProfileLedger +} from './claude-profile-sharing' + +export const CLAUDE_PROFILE_RESOURCE_DIRS = [ + 'skills', + 'plugins', + 'agents', + 'commands', + 'output-styles', + 'rules', + 'themes', + 'workflows' +] as const +// Copied, not linked: a rename-replace save (Claude's own, or an editor's) would cut a link. +export const CLAUDE_PROFILE_RESOURCE_FILES = ['CLAUDE.md', 'keybindings.json'] as const +export const CLAUDE_PROFILE_MEMORY_IMPORT = '@~/.claude/CLAUDE.md\n' +const PRIVATE_KEYS = new Set([ + 'apiKeyHelper', + 'awsAuthRefresh', + 'awsCredentialExport', + 'forceLoginMethod', + 'forceLoginOrgUUID' +]) +const PRIVATE_ENV = new Set([ + 'ANTHROPIC_API_KEY', + 'ANTHROPIC_AUTH_TOKEN', + 'CLAUDE_CODE_OAUTH_TOKEN' +]) +const SHARED_STATE_KEYS = ['mcpServers', 'theme'] + +// Why `hooks` is shared: Orca writes identical entries into every folder, so the installer that +// runs after the merge finds them present and the user's own hooks keep running in every account. +function pickSettings(source: Record): Record { + return Object.fromEntries( + Object.entries(source) + .filter(([key]) => !PRIVATE_KEYS.has(key)) + .map(([key, value]) => { + if (key === 'env' && value && typeof value === 'object' && !Array.isArray(value)) { + return [ + key, + Object.fromEntries(Object.entries(value).filter(([name]) => !PRIVATE_ENV.has(name))) + ] + } + return [key, value] + }) + ) +} + +function mergeSettings( + source: string, + target: string, + ledger: ClaudeProfileLedger +): ClaudeProfileSurfaceOutcome { + if (lstatIfPresent(target)?.isSymbolicLink()) { + return 'user-owned' + } + const existing = readClaudeProfileObject(target) + const input = readClaudeProfileObject(source) + if (existing.kind === 'unavailable' || input.kind === 'unavailable') { + throw new ClaudeProfileSurfaceError('unreadable', 'Claude settings.json is unreadable') + } + const config: Record = existing.kind === 'present' ? { ...existing.value } : {} + const desired = pickSettings(input.kind === 'present' ? input.value : {}) + const written = { ...ledger.keys['settings.json'] } + const changed = + mergeClaudeProfileKeys(config, desired, written).length + + dropClaudeProfileKeys(config, desired, written).length + if (changed > 0) { + writeFileAtomically(target, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 }) + } + // Committed only after the write, so a failed write never marks an unwritten value as shared. + ledger.keys['settings.json'] = written + return changed > 0 ? 'merged' : existing.kind === 'absent' ? 'absent' : 'unchanged' +} + +async function mergeState(args: { + source: string + target: string + ledger: ClaudeProfileLedger + trustKeys: readonly string[] + report: ClaudeProfileReport +}): Promise { + if (lstatIfPresent(args.target)?.isSymbolicLink()) { + return 'user-owned' + } + const input = readClaudeProfileObject(args.source) + if (input.kind === 'unavailable') { + // Why: onboarding and trust don't depend on the personal state; only its shared keys wait. + const error = new ClaudeProfileSurfaceError('unreadable', 'Personal Claude state is unreadable') + warnClaudeProfile(args.report, '.claude.json', error) + } + const source = input.kind === 'present' ? input.value : {} + const desired = Object.fromEntries( + SHARED_STATE_KEYS.filter((key) => key in source).map((key) => [key, source[key]]) + ) + let written: Record = {} + const outcome = await updateClaudeGlobalConfig(args.target, (current) => { + const config = { ...current } + written = { ...args.ledger.keys['.claude.json'] } + let changed = mergeClaudeProfileKeys(config, desired, written).length > 0 + if ( + input.kind !== 'unavailable' && + dropClaudeProfileKeys(config, desired, written).length > 0 + ) { + changed = true + } + // Why: otherwise first launch opens the onboarding wizard, where a stray Enter starts a login that rebinds the profile. + if (config.hasCompletedOnboarding !== true) { + config.hasCompletedOnboarding = true + changed = true + } + // Why: a malformed `projects` refuses only trust; onboarding and shared keys still apply. + const trust = args.trustKeys.length > 0 ? applyClaudeFolderTrust(config, args.trustKeys) : null + if (trust?.kind === 'changed') { + return trust + } + return changed ? { kind: 'changed', config } : { kind: 'unchanged' } + }) + if (outcome === 'missing-config') { + // Why: no state file means no completed login; writing one would fabricate an account. + return 'absent' + } + if (outcome === 'locked' || outcome === 'unreadable') { + throw new ClaudeProfileSurfaceError(outcome, `Profile Claude state is ${outcome}`) + } + args.ledger.keys['.claude.json'] = written + return outcome === 'updated' ? 'merged' : 'unchanged' +} + +/** + * Shares the default home's config into a profile. Execution-host paths; never touches credentials. + * Callers go through provisionClaudeAccountProfile, which gates and creates the profile. + */ +export async function provisionClaudeProfile(args: { + profileHome: string + userHome: string + /** The user's own CLAUDE_CONFIG_DIR; `~/.claude` when unset. */ + userConfigDir?: string + platform?: NodeJS.Platform + trustKeys?: readonly string[] +}): Promise { + const platform = args.platform ?? process.platform + const defaultHome = resolveClaudeDefaultHome(args.userHome, args.userConfigDir) + const report = createClaudeProfileReport() + const ledgerPath = claudeProfileLedgerPath(args.profileHome) + const ledger = readClaudeProfileLedger(ledgerPath) + const recorded = JSON.stringify(ledger) + for (const name of CLAUDE_PROFILE_RESOURCE_DIRS) { + await runClaudeProfileSurface(report, name, () => + linkClaudeProfileDirectory(join(defaultHome, name), join(args.profileHome, name), platform) + ) + } + // Why only for ~/.claude: Claude also loads it as a parent folder's memory for projects under + // home, so a copy would load twice; a custom CLAUDE_CONFIG_DIR is copied, as superset does. + const imported = resolve(defaultHome) === resolve(args.userHome, '.claude') + const memory = imported ? () => CLAUDE_PROFILE_MEMORY_IMPORT : undefined + for (const name of CLAUDE_PROFILE_RESOURCE_FILES) { + await runClaudeProfileSurface(report, name, () => + syncClaudeProfileFile( + join(defaultHome, name), + join(args.profileHome, name), + name, + ledger, + name === 'CLAUDE.md' ? memory : undefined + ) + ) + } + await runClaudeProfileSurface(report, 'settings.json', () => + mergeSettings( + join(defaultHome, 'settings.json'), + join(args.profileHome, 'settings.json'), + ledger + ) + ) + const statePath = (configDir: string | undefined): string => + resolveClaudeGlobalConfigFile({ + env: { CLAUDE_CONFIG_DIR: configDir }, + homeDir: args.userHome, + style: platform === 'win32' ? 'win32' : 'posix', + exists: existsSync + }) + await runClaudeProfileSurface(report, '.claude.json', () => + mergeState({ + source: statePath(args.userConfigDir), + target: statePath(args.profileHome), + ledger, + trustKeys: args.trustKeys ?? [], + report + }) + ) + await runClaudeProfileSurface(report, 'ledger', () => { + if (JSON.stringify(ledger) === recorded) { + return 'unchanged' + } + writeClaudeProfileLedger(ledgerPath, ledger) + return 'synced' + }) + return report +} diff --git a/src/main/claude-accounts/claude-profile-report.ts b/src/main/claude-accounts/claude-profile-report.ts new file mode 100644 index 00000000000..2a5baae068e --- /dev/null +++ b/src/main/claude-accounts/claude-profile-report.ts @@ -0,0 +1,86 @@ +import type { CLAUDE_PROFILE_HISTORY_DIRS } from './claude-profile-history' +import type { + CLAUDE_PROFILE_RESOURCE_DIRS, + CLAUDE_PROFILE_RESOURCE_FILES +} from './claude-profile-provisioning' + +export type ClaudeProfileSurface = + | 'profile' + | (typeof CLAUDE_PROFILE_HISTORY_DIRS)[number] + | 'history.jsonl' + | (typeof CLAUDE_PROFILE_RESOURCE_DIRS)[number] + | (typeof CLAUDE_PROFILE_RESOURCE_FILES)[number] + | 'settings.json' + | '.claude.json' + | 'ledger' + | 'hooks' + +export type ClaudeProfileSurfaceOutcome = + | 'linked' + | 'synced' + | 'merged' + | 'unchanged' + | 'user-owned' + | 'absent' + | 'failed' + +/** Closed so callers branch on a code, never on message text. */ +export type ClaudeProfileWarningCode = + | 'invalid-profile' + | 'unreadable' + | 'locked' + | 'cross-filesystem' + | 'retained-conflict' + | 'link-failed' + | 'failed' + +export type ClaudeProfileWarning = { + surface: ClaudeProfileSurface + code: ClaudeProfileWarningCode + /** For logs only. */ + detail: string +} + +export type ClaudeProfileReport = { + surfaces: Partial> + warnings: ClaudeProfileWarning[] +} + +export class ClaudeProfileSurfaceError extends Error { + readonly code: ClaudeProfileWarningCode + + constructor(code: ClaudeProfileWarningCode, message: string) { + super(message) + this.code = code + } +} + +export function createClaudeProfileReport(): ClaudeProfileReport { + return { surfaces: {}, warnings: [] } +} + +export function warnClaudeProfile( + report: ClaudeProfileReport, + surface: ClaudeProfileSurface, + error: unknown +): void { + report.warnings.push({ + surface, + code: error instanceof ClaudeProfileSurfaceError ? error.code : 'failed', + detail: error instanceof Error ? error.message : String(error) + }) +} + +/** One surface's failure is reported and never stops the surfaces after it. */ +export async function runClaudeProfileSurface( + report: ClaudeProfileReport, + surface: ClaudeProfileSurface, + operation: () => ClaudeProfileSurfaceOutcome | Promise +): Promise { + try { + report.surfaces[surface] = await operation() + } catch (error) { + report.surfaces[surface] = 'failed' + warnClaudeProfile(report, surface, error) + } +} diff --git a/src/main/claude-accounts/claude-profile-setup.test.ts b/src/main/claude-accounts/claude-profile-setup.test.ts new file mode 100644 index 00000000000..0195805eff3 --- /dev/null +++ b/src/main/claude-accounts/claude-profile-setup.test.ts @@ -0,0 +1,160 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import type * as Os from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +const state = vi.hoisted(() => ({ home: '' })) +vi.mock('node:os', async (original) => ({ + ...(await original()), + homedir: () => state.home +})) +vi.mock('electron', () => ({ app: { getPath: () => state.home } })) +import { ClaudeHookService } from '../claude/hook-service' +import { describeClaudeProfile } from './claude-profile-paths' +import { provisionClaudeAccountProfile } from './claude-profile-setup' + +const roots: string[] = [] +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) +const local = { runtime: 'host', executionHostId: 'local' } as const +function fixture() { + const root = realpathSync(mkdtempSync(join(tmpdir(), 'claude-profile-setup-'))) + roots.push(root) + state.home = join(root, 'home') + const defaultHome = join(state.home, '.claude') + const dataRoot = join(root, 'data') + mkdirSync(defaultHome, { recursive: true }) + mkdirSync(dataRoot) + const service = new ClaudeHookService() + const installHooks = (target: { configDir: string }) => + service.install({ claudeVersion: '2.1.261', ...target }) + const setup = () => + provisionClaudeAccountProfile({ + dataRoot, + profile: describeClaudeProfile(dataRoot, 'a', local), + userHome: state.home, + installHooks, + platform: 'linux' + }) + return { root, defaultHome, dataRoot, service, setup } +} + +// Why: setup runs as 'linux', so it creates real symlinks, which Windows needs privilege for. +const itLinks = it.skipIf(process.platform === 'win32') + +describe('Claude account profile setup', () => { + itLinks('prepares, shares history, provisions and installs hooks in one call', async () => { + const f = fixture() + mkdirSync(join(f.defaultHome, 'skills')) + writeFileSync(join(f.defaultHome, 'settings.json'), '{"model":"opus"}') + f.service.install({ claudeVersion: '2.1.261' }) + const report = await f.setup() + const home = join(f.dataRoot, 'claude-profiles/a/home') + expect(report).toMatchObject({ outcome: 'prepared', warnings: [] }) + expect(report.surfaces).toMatchObject({ + projects: 'linked', + 'history.jsonl': 'linked', + skills: 'linked', + 'settings.json': 'merged', + '.claude.json': 'absent', + hooks: 'merged' + }) + expect(existsSync(join(f.dataRoot, 'claude-profiles/a/profile.json'))).toBe(true) + const settings = JSON.parse(readFileSync(join(home, 'settings.json'), 'utf8')) + const defaults = JSON.parse(readFileSync(join(f.defaultHome, 'settings.json'), 'utf8')) + expect(settings).toEqual({ + model: 'opus', + hooks: defaults.hooks, + statusLine: defaults.statusLine + }) + expect(realpathSync(join(home, 'projects'))).toBe(realpathSync(join(f.defaultHome, 'projects'))) + expect(existsSync(join(home, '.credentials.json'))).toBe(false) + expect((await f.setup()).warnings).toEqual([]) + }) + itLinks("brings the user's later hooks into an account set up while they had none", async () => { + const f = fixture() + const settings = join(f.defaultHome, 'settings.json') + writeFileSync(settings, '{"model":"opus"}') + await f.setup() + const home = join(f.dataRoot, 'claude-profiles/a/home') + const read = (file: string) => JSON.parse(readFileSync(file, 'utf8')) + const orca = read(join(home, 'settings.json')).hooks + expect(orca).toBeDefined() + const mine = { matcher: '', hooks: [{ type: 'command', command: 'notify-me' }] } + writeFileSync(settings, JSON.stringify({ model: 'opus', hooks: { Notification: [mine] } })) + expect((await f.setup()).warnings).toEqual([]) + const hooks = read(join(home, 'settings.json')).hooks + expect(hooks.Notification).toContainEqual(mine) + expect(hooks.Stop).toEqual(orca.Stop) + }) + itLinks('refuses another account in the same slot without creating anything', async () => { + const f = fixture() + await f.setup() + writeFileSync( + join(f.dataRoot, 'claude-profiles/a/profile.json'), + JSON.stringify({ version: 1, accountId: 'b', runtime: 'host' }) + ) + rmSync(join(f.dataRoot, 'claude-profiles/a/home'), { recursive: true }) + const report = await f.setup() + expect(report.outcome).toBe('refused') + expect(report.warnings).toEqual([ + expect.objectContaining({ surface: 'profile', code: 'invalid-profile' }) + ]) + expect(existsSync(join(f.dataRoot, 'claude-profiles/a/home'))).toBe(false) + }) + itLinks('refuses a data root linked into the default home before writing there', async () => { + const f = fixture() + mkdirSync(join(f.defaultHome, 'inner')) + symlinkSync(join(f.defaultHome, 'inner'), join(f.dataRoot, 'claude-profiles')) + expect((await f.setup()).outcome).toBe('refused') + expect(existsSync(join(f.defaultHome, 'inner', 'a'))).toBe(false) + }) + it('refuses a WSL profile on the Windows host side', async () => { + fixture() + const report = await provisionClaudeAccountProfile({ + dataRoot: '/home/u/.local/share/orca', + profile: describeClaudeProfile('/home/u/.local/share/orca', 'a', { + runtime: 'wsl', + distro: 'Ubuntu', + executionHostId: 'local' + }), + userHome: state.home, + installHooks: null, + platform: 'win32' + }) + expect(report.outcome).toBe('refused') + expect(report.warnings).toEqual([ + expect.objectContaining({ surface: 'profile', code: 'invalid-profile' }) + ]) + }) + itLinks('reports skipped and failed hook installs without failing the rest', async () => { + const f = fixture() + const skipped = await provisionClaudeAccountProfile({ + dataRoot: f.dataRoot, + profile: describeClaudeProfile(f.dataRoot, 'a', local), + userHome: state.home, + installHooks: null, + platform: 'linux' + }) + expect(skipped.surfaces.hooks).toBe('absent') + writeFileSync(join(f.dataRoot, 'claude-profiles/a/home/settings.json'), '{bad') + const failed = await f.setup() + expect(failed.surfaces.hooks).toBe('failed') + expect(failed.surfaces.projects).toBe('unchanged') + expect(failed.warnings).toContainEqual( + expect.objectContaining({ surface: 'hooks', code: 'failed' }) + ) + }) +}) diff --git a/src/main/claude-accounts/claude-profile-setup.ts b/src/main/claude-accounts/claude-profile-setup.ts new file mode 100644 index 00000000000..1687d9cf0e8 --- /dev/null +++ b/src/main/claude-accounts/claude-profile-setup.ts @@ -0,0 +1,96 @@ +import { join } from 'node:path' +import type { AgentHookInstallStatus } from '../../shared/agent-hook-types' +import { shareClaudeProfileHistory } from './claude-profile-history' +import { + prepareClaudeProfileDirectory, + readClaudeProfileObject, + type ClaudeProfileDescriptor +} from './claude-profile-paths' +import { provisionClaudeProfile } from './claude-profile-provisioning' +import { + ClaudeProfileSurfaceError, + createClaudeProfileReport, + runClaudeProfileSurface, + warnClaudeProfile, + type ClaudeProfileReport +} from './claude-profile-report' +import { + claudeProfileLedgerPath, + readClaudeProfileLedger, + writeClaudeProfileLedger +} from './claude-profile-sharing' + +/** Without this the installed `hooks` look like a profile edit, so the user's own hooks never arrive. */ +function recordInstalledHooks(home: string): void { + const settings = readClaudeProfileObject(join(home, 'settings.json')) + if (settings.kind !== 'present' || !('hooks' in settings.value)) { + return + } + const ledgerPath = claudeProfileLedgerPath(home) + const ledger = readClaudeProfileLedger(ledgerPath) + const written = (ledger.keys['settings.json'] ??= {}) + const hooks = JSON.stringify(settings.value.hooks) + if (written.hooks !== hooks) { + written.hooks = hooks + writeClaudeProfileLedger(ledgerPath, ledger) + } +} + +/** `refused`: the profile failed its ownership gate and no surface was touched. */ +export type ClaudeProfileSetupReport = ClaudeProfileReport & { outcome: 'refused' | 'prepared' } + +/** + * The one entry for setting up a managed Claude profile. Runs on the execution host that owns the + * profile; for WSL that is inside the guest, never across a UNC path. + */ +export async function provisionClaudeAccountProfile(args: { + dataRoot: string + profile: ClaudeProfileDescriptor + userHome: string + /** The user's own CLAUDE_CONFIG_DIR (see readUserClaudeConfigDir); `~/.claude` when unset. */ + userConfigDir?: string + /** Null when Orca's Claude hooks are turned off. Runs after the settings merge so its entries survive it. */ + installHooks: ((target: { configDir: string }) => AgentHookInstallStatus) | null + trustKeys?: readonly string[] + platform?: NodeJS.Platform +}): Promise { + const platform = args.platform ?? process.platform + const report = createClaudeProfileReport() + try { + if (args.profile.target.runtime === 'wsl' && platform === 'win32') { + throw new ClaudeProfileSurfaceError('invalid-profile', 'WSL profiles are set up in the guest') + } + prepareClaudeProfileDirectory(args.dataRoot, args.profile, args.userHome, args.userConfigDir) + } catch (error) { + report.surfaces.profile = 'failed' + warnClaudeProfile(report, 'profile', error) + return { outcome: 'refused', ...report } + } + const home = args.profile.home + const shared = { profileHome: home, userHome: args.userHome, userConfigDir: args.userConfigDir } + for (const step of [ + () => shareClaudeProfileHistory({ ...shared, platform }), + () => provisionClaudeProfile({ ...shared, platform, trustKeys: args.trustKeys }) + ]) { + try { + const part = await step() + Object.assign(report.surfaces, part.surfaces) + report.warnings.push(...part.warnings) + } catch (error) { + warnClaudeProfile(report, 'profile', error) + } + } + const installHooks = args.installHooks + await runClaudeProfileSurface(report, 'hooks', () => { + if (!installHooks) { + return 'absent' + } + const status = installHooks({ configDir: home }) + if (status.state !== 'installed') { + throw new Error(status.detail ?? `Claude hooks ${status.state}`) + } + recordInstalledHooks(home) + return 'merged' + }) + return { outcome: 'prepared', ...report } +} diff --git a/src/main/claude-accounts/claude-profile-sharing.ts b/src/main/claude-accounts/claude-profile-sharing.ts new file mode 100644 index 00000000000..69201b62f5b --- /dev/null +++ b/src/main/claude-accounts/claude-profile-sharing.ts @@ -0,0 +1,189 @@ +import { createHash } from 'node:crypto' +import { + lstatSync, + mkdirSync, + readFileSync, + readdirSync, + realpathSync, + rmdirSync, + symlinkSync, + unlinkSync +} from 'node:fs' +import { dirname, join } from 'node:path' +import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' +import { writeFileAtomically } from '../codex-accounts/fs-utils' +import { readClaudeProfileObject } from './claude-profile-paths' +import type { ClaudeProfileSurfaceOutcome } from './claude-profile-report' + +/** Keyed by surface name, not path, so another spelling of the same profile keeps its history. */ +export type ClaudeProfileLedger = { + version: 1 + files: Record + keys: Record> +} + +function stringEntries(value: unknown): Record { + const entries: Record = {} + if (value && typeof value === 'object') { + for (const [key, entry] of Object.entries(value)) { + if (typeof entry === 'string') { + entries[key] = entry + } + } + } + return entries +} + +export function claudeProfileLedgerPath(profileHome: string): string { + return join(profileHome, '.orca-profile.json') +} + +export function writeClaudeProfileLedger(file: string, ledger: ClaudeProfileLedger): void { + writeFileAtomically(file, `${JSON.stringify(ledger, null, 2)}\n`, { mode: 0o600 }) +} + +/** Orca's own bookkeeping: an unreadable ledger starts empty, so nothing shared is overwritten. */ +export function readClaudeProfileLedger(file: string): ClaudeProfileLedger { + const ledger: ClaudeProfileLedger = { version: 1, files: {}, keys: {} } + const result = readClaudeProfileObject(file) + if (result.kind !== 'present') { + return ledger + } + ledger.files = stringEntries(result.value.files) + const keys = result.value.keys + if (keys && typeof keys === 'object') { + for (const [surface, entries] of Object.entries(keys)) { + ledger.keys[surface] = stringEntries(entries) + } + } + return ledger +} + +export function linkClaudeProfileDirectory( + source: string, + target: string, + platform: NodeJS.Platform +): ClaudeProfileSurfaceOutcome { + let canonical: string + try { + canonical = realpathSync(source) + } catch (error) { + if (isDefinitiveAbsence(error)) { + return 'absent' + } + throw error + } + let entry: ReturnType | undefined + try { + entry = lstatSync(target) + } catch (error) { + if (!isDefinitiveAbsence(error)) { + throw error + } + } + if (entry?.isSymbolicLink()) { + try { + return realpathSync(target) === canonical ? 'unchanged' : 'user-owned' + } catch (error) { + if (!isDefinitiveAbsence(error)) { + throw error + } + unlinkSync(target) + } + } else if (entry) { + if (!entry.isDirectory() || readdirSync(target).length > 0) { + return 'user-owned' + } + rmdirSync(target) + } + mkdirSync(dirname(target), { recursive: true }) + // Why: link the path itself so a user who re-points their own link is followed. + symlinkSync(source, target, platform === 'win32' ? 'junction' : 'dir') + return 'linked' +} + +export function syncClaudeProfileFile( + source: string, + target: string, + surface: string, + ledger: ClaudeProfileLedger, + render: (sourceText: string) => string = (sourceText) => sourceText +): ClaudeProfileSurfaceOutcome { + let desired: string + try { + desired = render(readFileSync(source, 'utf8')) + } catch (error) { + if (isDefinitiveAbsence(error)) { + return 'absent' + } + throw error + } + const hash = (value: string): string => createHash('sha256').update(value).digest('hex') + try { + if (lstatSync(target).isSymbolicLink()) { + return 'user-owned' + } + const current = hash(readFileSync(target, 'utf8')) + if (current === hash(desired)) { + ledger.files[surface] = current + return 'unchanged' + } + if (ledger.files[surface] !== current) { + return 'user-owned' + } + } catch (error) { + if (!isDefinitiveAbsence(error)) { + throw error + } + } + writeFileAtomically(target, desired, { mode: 0o600 }) + ledger.files[surface] = hash(desired) + return 'synced' +} + +/** Returns the keys it changed in `target`. */ +export function mergeClaudeProfileKeys( + target: Record, + desired: Record, + written: Record +): string[] { + const changed: string[] = [] + for (const [key, value] of Object.entries(desired)) { + const serialized = JSON.stringify(value) + const current = key in target ? JSON.stringify(target[key]) : undefined + if (current !== serialized && current !== undefined && written[key] !== current) { + continue + } + if (current !== serialized) { + target[key] = value + changed.push(key) + } + written[key] = serialized + } + return changed +} + +/** + * A key the default home dropped leaves the profile when the profile still holds what Orca last + * shared; a value changed inside the profile stays. Callers skip this when the source was unreadable. + */ +export function dropClaudeProfileKeys( + target: Record, + desired: Record, + written: Record +): string[] { + const dropped: string[] = [] + for (const key of Object.keys(written)) { + if (key in desired) { + continue + } + if (!(key in target)) { + delete written[key] + } else if (JSON.stringify(target[key]) === written[key]) { + delete target[key] + delete written[key] + dropped.push(key) + } + } + return dropped +} diff --git a/src/main/claude/claude-folder-trust-file.ts b/src/main/claude/claude-folder-trust-file.ts index 5ec9bccebcc..dc755bf3d2d 100644 --- a/src/main/claude/claude-folder-trust-file.ts +++ b/src/main/claude/claude-folder-trust-file.ts @@ -37,9 +37,9 @@ type ClaudeConfigEnv = { // half-stale refresh timer stays inside setTimeout's 32-bit range. const NEVER_STALE_MS = 2 ** 30 const LOCK_RETRIES = { retries: 4, factor: 2, minTimeout: 50, maxTimeout: 250 } -// Why: concurrent grants in one process retry the file lock in lockstep, so a launch burst +// Why: concurrent updates in one process retry the file lock in lockstep, so a launch burst // would lose most of them to `locked`; queue them so only Claude itself contends for the lock. -const grantQueueByConfigFile = new Map>() +const updateQueueByConfigFile = new Map>() function pathApi(style: ClaudeTrustPathStyle): typeof posix { return style === 'win32' ? win32 : posix @@ -75,7 +75,7 @@ function isPlainObject(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } -export type ClaudeFolderTrustChange = +export type ClaudeGlobalConfigChange = | { kind: 'unchanged' } | { kind: 'refuse' } | { kind: 'changed'; config: Record } @@ -83,7 +83,7 @@ export type ClaudeFolderTrustChange = export function applyClaudeFolderTrust( config: Record, folderKeys: readonly string[] -): ClaudeFolderTrustChange { +): ClaudeGlobalConfigChange { if (config.projects !== undefined && !isPlainObject(config.projects)) { return { kind: 'refuse' } } @@ -193,30 +193,45 @@ function replaceConfig(replacement: ReplacementFile, config: Record].hasTrustDialogAccepted` in Claude's global config. Never - * creates the file, never breaks Claude's lock, and never rewrites a file it could - * not read and parse. - */ -export function grantClaudeFolderTrust(args: { +/** Sets `projects[].hasTrustDialogAccepted` in Claude's global config. */ +export async function grantClaudeFolderTrust(args: { configFile: string folderKeys: readonly string[] }): Promise { - return runKeyedSerializedOperation(grantQueueByConfigFile, args.configFile, () => - grantClaudeFolderTrustNow(args) + const outcome = await updateClaudeGlobalConfig(args.configFile, (config) => + applyClaudeFolderTrust(config, args.folderKeys) + ) + return outcome === 'updated' ? 'granted' : outcome +} + +export type ClaudeGlobalConfigUpdateOutcome = + | Exclude + | 'updated' + +/** + * Orca's one writer of a Claude global config. Never creates the file, never breaks Claude's + * lock, and never rewrites a file it could not read and parse. `change` must be pure: it runs + * once to plan and again under the lock. + */ +export function updateClaudeGlobalConfig( + configFile: string, + change: (config: Record) => ClaudeGlobalConfigChange +): Promise { + return runKeyedSerializedOperation(updateQueueByConfigFile, configFile, () => + updateClaudeGlobalConfigNow({ configFile, change }) ) } -async function grantClaudeFolderTrustNow(args: { +async function updateClaudeGlobalConfigNow(args: { configFile: string - folderKeys: readonly string[] -}): Promise { + change: (config: Record) => ClaudeGlobalConfigChange +}): Promise { const probe = readConfigAt(resolveConfigTarget(args.configFile)) if (typeof probe === 'string') { return probe } // Why: most launches need nothing, so skip Claude's lock unless a write is due. - const planned = applyClaudeFolderTrust(probe.config, args.folderKeys).kind + const planned = args.change(probe.config).kind if (planned !== 'changed') { return planned === 'refuse' ? 'unreadable' : 'unchanged' } @@ -249,7 +264,7 @@ async function grantClaudeFolderTrustNow(args: { if (current.path !== replacement.target) { return 'unreadable' } - const change = applyClaudeFolderTrust(current.config, args.folderKeys) + const change = args.change(current.config) if (change.kind === 'refuse') { return 'unreadable' } @@ -257,7 +272,7 @@ async function grantClaudeFolderTrustNow(args: { return 'unchanged' } replaceConfig(replacement, change.config) - return 'granted' + return 'updated' } finally { await release().catch(() => {}) } diff --git a/src/main/claude/claude-profile-hook-target.ts b/src/main/claude/claude-profile-hook-target.ts new file mode 100644 index 00000000000..6e206f480d9 --- /dev/null +++ b/src/main/claude/claude-profile-hook-target.ts @@ -0,0 +1,42 @@ +import { statSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import type { AgentHookInstallStatus } from '../../shared/agent-hook-types' +import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' +import { getConfigPath, type ClaudeCompatibleHookSettings } from './hook-settings' + +function sameFile(left: string, right: string): boolean { + if (resolve(left) === resolve(right)) { + return true + } + try { + // Why: file identity, not spelling, so links and case-only aliases both compare equal. + const leftStats = statSync(left, { bigint: true }) + const rightStats = statSync(right, { bigint: true }) + return leftStats.dev === rightStats.dev && leftStats.ino === rightStats.ino + } catch (error) { + // Why: only a definitive absence proves they differ; any other failure refuses. + return !isDefinitiveAbsence(error) + } +} + +/** A profile destination that is, or links into, the default home would edit System Default's hooks. */ +export function refuseProfileAtDefaultHome( + service: { agent: AgentHookInstallStatus['agent']; settings: ClaudeCompatibleHookSettings }, + configDir: string | undefined +): AgentHookInstallStatus | null { + if (configDir === undefined) { + return null + } + const configPath = getConfigPath(service.settings, configDir) + const defaultSettings = getConfigPath(service.settings) + if (!sameFile(configDir, dirname(defaultSettings)) && !sameFile(configPath, defaultSettings)) { + return null + } + return { + agent: service.agent, + state: 'error', + configPath, + managedHooksPresent: false, + detail: 'Profile settings resolve to the default home' + } +} diff --git a/src/main/claude/claude-profile-hooks.test.ts b/src/main/claude/claude-profile-hooks.test.ts new file mode 100644 index 00000000000..35db45340b0 --- /dev/null +++ b/src/main/claude/claude-profile-hooks.test.ts @@ -0,0 +1,155 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import type * as Os from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +const state = vi.hoisted(() => ({ home: '' })) +vi.mock('node:os', async (original) => ({ + ...(await original()), + homedir: () => state.home +})) +vi.mock('electron', () => ({ app: { getPath: () => state.home } })) +import { ClaudeHookService } from './hook-service' +import { provisionClaudeProfile } from '../claude-accounts/claude-profile-provisioning' + +// Case-only aliases exist only on a case-insensitive filesystem (default APFS, NTFS). +const caseInsensitive = (() => { + const dir = mkdtempSync(join(tmpdir(), 'claude-case-probe-')) + try { + mkdirSync(join(dir, 'probe')) + return existsSync(join(dir, 'PROBE')) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +})() +const roots: string[] = [] +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) +const CURRENT = { claudeVersion: '2.1.261' } +function fixture() { + state.home = mkdtempSync(join(tmpdir(), 'claude-profile-hooks-')) + roots.push(state.home) + const defaultDir = join(state.home, '.claude') + const profile = join(state.home, 'profile') + mkdirSync(defaultDir) + mkdirSync(profile) + const settings = (dir: string): Record => + JSON.parse(readFileSync(join(dir, 'settings.json'), 'utf8')) + const edit = (dir: string, change: (value: Record) => void): void => { + const value = settings(dir) + change(value) + writeFileSync(join(dir, 'settings.json'), JSON.stringify(value)) + } + const service = new ClaudeHookService() + const installProfile = () => service.install({ ...CURRENT, configDir: profile }) + const provision = () => + provisionClaudeProfile({ profileHome: profile, userHome: state.home, platform: 'linux' }) + return { defaultDir, profile, settings, edit, service, installProfile, provision } +} + +function stopHooks(f: ReturnType) { + const hook = (command: string) => ({ matcher: '', hooks: [{ type: 'command', command }] }) + const stopOf = (value: Record): unknown[] => { + const hooks: unknown = value.hooks + return hooks && typeof hooks === 'object' && 'Stop' in hooks && Array.isArray(hooks.Stop) + ? hooks.Stop + : [] + } + const isOrca = (entry: unknown) => JSON.stringify(entry).includes('agent-hooks') + const setStop = (dir: string, commands: string[]) => + f.edit(dir, (value) => { + const hooks = typeof value.hooks === 'object' ? value.hooks : {} + value.hooks = { ...hooks, Stop: [...commands.map(hook), ...stopOf(value).filter(isOrca)] } + }) + const stop = (dir: string) => stopOf(f.settings(dir)) + const orcaCount = (dir: string) => stop(dir).filter(isOrca).length + return { hook, setStop, stop, orcaCount } +} +const sync = async (f: ReturnType) => { + const report = await f.provision() + expect(f.installProfile().state).toBe('installed') + return report +} + +// Why: these create real symlinks, which Windows needs privilege for. +const itLinks = it.skipIf(process.platform === 'win32') + +describe('Claude hooks at an explicit profile', () => { + it('installs managed hooks at a profile without editing default settings or adding a statusline', () => { + const f = fixture() + writeFileSync(join(f.defaultDir, 'settings.json'), '{"model":"default"}') + writeFileSync(join(f.profile, 'settings.json'), '{"model":"profile"}') + expect(f.service.install(CURRENT).state).toBe('installed') + const before = readFileSync(join(f.defaultDir, 'settings.json'), 'utf8') + const result = f.installProfile() + expect(result.configPath).toBe(join(f.profile, 'settings.json')) + expect(result.state).toBe('installed') + expect(readFileSync(join(f.defaultDir, 'settings.json'), 'utf8')).toBe(before) + expect(f.settings(f.profile)).toEqual({ model: 'profile', hooks: JSON.parse(before).hooks }) + }) + it("shares the user's hooks, Orca's hooks and statusline as plain keys that installs leave in sync", async () => { + const f = fixture() + const { hook, setStop, stop, orcaCount } = stopHooks(f) + f.service.install(CURRENT) + setStop(f.defaultDir, ['notify-me']) + await sync(f) + expect(f.settings(f.profile).hooks).toEqual(f.settings(f.defaultDir).hooks) + expect(f.settings(f.profile).statusLine).toEqual(f.settings(f.defaultDir).statusLine) + expect((await sync(f)).surfaces['settings.json']).toBe('unchanged') + setStop(f.defaultDir, ['notify-v2']) + await sync(f) + expect(stop(f.profile)[0]).toEqual(hook('notify-v2')) + expect(orcaCount(f.profile)).toBe(1) + setStop(f.profile, ['profile-only']) + setStop(f.defaultDir, ['notify-v3']) + await sync(f) + expect(stop(f.profile)[0]).toEqual(hook('profile-only')) + expect(orcaCount(f.profile)).toBe(1) + }) + it('carries a default-home statusline opt-out and an old-Claude retire to the profile', async () => { + const f = fixture() + f.service.install(CURRENT) + await sync(f) + expect(f.settings(f.profile).statusLine).toBeDefined() + f.edit(f.defaultDir, (value) => delete value.statusLine) + await sync(f) + expect(f.settings(f.profile).statusLine).toBeUndefined() + const old = fixture() + old.service.install(CURRENT) + await sync(old) + old.service.install({ claudeVersion: '1.0.0' }) + await old.provision() + old.service.install({ claudeVersion: '1.0.0', configDir: old.profile }) + expect(old.settings(old.defaultDir).statusLine).toBeUndefined() + expect(old.settings(old.profile).statusLine).toBeUndefined() + }) + itLinks('refuses a profile destination that is or links into the default home', () => { + const f = fixture() + f.service.install(CURRENT) + const defaults = readFileSync(join(f.defaultDir, 'settings.json'), 'utf8') + rmSync(join(f.profile, 'settings.json'), { force: true }) + symlinkSync(join(f.defaultDir, 'settings.json'), join(f.profile, 'settings.json')) + expect(f.installProfile().state).toBe('error') + expect(f.service.install({ ...CURRENT, configDir: f.defaultDir }).state).toBe('error') + expect(readFileSync(join(f.defaultDir, 'settings.json'), 'utf8')).toBe(defaults) + }) + it.runIf(caseInsensitive)('refuses a case-only alias of the default home', () => { + const f = fixture() + f.service.install(CURRENT) + const defaults = readFileSync(join(f.defaultDir, 'settings.json'), 'utf8') + const alias = join(state.home, '.CLAUDE') + expect(f.service.install({ ...CURRENT, configDir: alias }).state).toBe('error') + expect(readFileSync(join(f.defaultDir, 'settings.json'), 'utf8')).toBe(defaults) + }) +}) diff --git a/src/main/claude/hook-service.ts b/src/main/claude/hook-service.ts index 0031d8413f9..e71fd3b5673 100644 --- a/src/main/claude/hook-service.ts +++ b/src/main/claude/hook-service.ts @@ -24,6 +24,7 @@ import { export { getManagedScript } import { getManagedStatusLineScript } from './statusline-script' +import { refuseProfileAtDefaultHome } from './claude-profile-hook-target' import { applyManagedHooks, applyManagedStatusLine, @@ -64,6 +65,11 @@ type ClaudeHookInstallOptions = { claudeVersion?: string } +type ClaudeHookTargetOptions = ClaudeHookInstallOptions & { + /** An account profile on this host; omitted for the default home. */ + configDir?: string +} + const DEFAULT_CLAUDE_HOOK_SERVICE_OPTIONS: ClaudeHookServiceOptions = { agent: 'claude', displayName: 'Claude', @@ -97,8 +103,12 @@ export class ClaudeHookService { : (this.options.hookPlan ?? OPENCLAUDE_MANAGED_HOOK_PLAN) } - getStatus(options: ClaudeHookInstallOptions = {}): AgentHookInstallStatus { - const configPath = getConfigPath(this.options.settings) + getStatus(options: ClaudeHookTargetOptions = {}): AgentHookInstallStatus { + const refused = refuseProfileAtDefaultHome(this.options, options.configDir) + if (refused) { + return refused + } + const configPath = getConfigPath(this.options.settings, options.configDir) const scriptPath = getManagedScriptPath(this.options.settings) const config = readHooksJson(configPath) if (!config) { @@ -158,8 +168,12 @@ export class ClaudeHookService { ) } - install(options: ClaudeHookInstallOptions = {}): AgentHookInstallStatus { - const configPath = getConfigPath(this.options.settings) + install(options: ClaudeHookTargetOptions = {}): AgentHookInstallStatus { + const refused = refuseProfileAtDefaultHome(this.options, options.configDir) + if (refused) { + return refused + } + const configPath = getConfigPath(this.options.settings, options.configDir) const scriptPath = getManagedScriptPath(this.options.settings) const config = readHooksJson(configPath) if (!config) { @@ -186,9 +200,10 @@ export class ClaudeHookService { } else { writeManagedScript(scriptPath, payload) } - if (plan.statusLine === 'install') { + // Why: a profile's statusLine arrives with the settings merge from the default home. + if (options.configDir === undefined && plan.statusLine === 'install') { nextConfig = this.installManagedStatusLine(nextConfig) - } else if (plan.statusLine === 'retire') { + } else if (options.configDir === undefined && plan.statusLine === 'retire') { nextConfig = this.retireManagedStatusLine(nextConfig) } writeHooksJson(configPath, nextConfig) diff --git a/src/main/claude/hook-settings.ts b/src/main/claude/hook-settings.ts index 40449344f20..ea418bc6bcc 100644 --- a/src/main/claude/hook-settings.ts +++ b/src/main/claude/hook-settings.ts @@ -42,8 +42,8 @@ export const OPENCLAUDE_HOOK_SETTINGS: ClaudeCompatibleHookSettings = { usesWindowsCompatLauncher: false } -export function getConfigPath(settings = CLAUDE_HOOK_SETTINGS): string { - return join(homedir(), settings.configDirName, 'settings.json') +export function getConfigPath(settings = CLAUDE_HOOK_SETTINGS, configDir?: string): string { + return join(configDir ?? join(homedir(), settings.configDirName), 'settings.json') } export function getStatusLineScriptBaseName(settings = CLAUDE_HOOK_SETTINGS): string {