mirror of
https://github.com/stablyai/orca.git
synced 2026-10-09 00:02:39 +00:00
feat(claude): prepare account profiles and shared history (Step 1 of 4) (#24300)
* feat(claude): add dormant profile setup and history sharing * fix(claude): make profile setup one gated, typed, fail-safe entry Review round 1 of the dormant profile setup found that the pieces could be called without their safety checks, that one failed write or an unreadable bookkeeping file could silently stop sharing for good, and that Windows prompt history could bring back history the user cleared. - One entry, provisionClaudeAccountProfile: the profile gate (namespace, no linked components, outside ~/.claude and ~/.config/claude, and an ownership marker beside the home naming the account and target) runs first and refuses before creating anything; then history sharing, config provisioning, and the hook install after the settings merge. Results come back per surface with closed warning codes instead of message text. - The sharing ledger is keyed by surface name, records a value only after its write succeeded, and an unreadable ledger starts empty and is rewritten instead of blocking every surface. - The profile state file goes through the same locked writer as folder trust (Claude's <file>.lock plus the in-process queue), generalized as updateClaudeGlobalConfig. Onboarding and trust are still applied when the personal state file is unreadable. - WSL descriptors build guest POSIX paths; the state-file path style follows the injected platform. - Orca's managed statusLine has one owner in a profile: the settings merge never shares it, a user's own statusLine is shared over it, and the profile installer follows the default home's slot so a default opt-out reaches every profile. remove() takes the same destination; the remote installer cannot accept one. - Prompt history compares file identity (bigint dev+ino) on every platform, never drains the shared file into itself, drains retained copies in generation order, never reuses a stale cursor, and on Windows keeps a replaced default's old copy aside instead of replaying it. Directory merges keep going past a failed entry. * fix(claude): share the user's own hooks and keep merged history whole A user's own Claude hooks in ~/.claude (notifications, formatters) did not run under a managed account, because the whole hooks key stayed private. They are now shared like any other settings key: Orca's own hook entries and its managed statusLine are stripped from both the personal value and the profile's current value before the per-key ledger comparison, so they never travel through the merge and never make the key look user-owned. Orca entries already in the profile are kept on write, and the profile hook installer adds them on top as before. Prompt history: merged bytes that lack a final newline are terminated, so Claude's next record no longer fuses onto the last merged line. When a CLI rewrote the profile's history file (old records plus new), only the lines past the part it shares with the default history are added, instead of the whole file again. * fix(claude): close review round 2 gaps in profile setup Hooks and statusLine sharing: - When ~/.claude holds only Orca's hook entries, the user's shared hooks now read as an empty value instead of a missing key. Removing the user's last own hook in ~/.claude therefore reaches profiles that never edited it, and deleting the only shared hook inside a profile stays deleted. - A custom statusLine Orca shared, and the profile never edited, goes away when the default home drops it. When a shared custom line replaced Orca's line in a profile, the profile's statusline marker is dropped so Orca's line comes back once the default returns to it; a profile that opted out stays opted out. No other key gains deletion. - install/remove/getStatus with a profile directory refuse when it is the default home, or its settings.json resolves to the default one, instead of editing System Default's hooks and opt-out state. - The profile statusline rule reads the default settings under the userHome passed to the setup entry, not os.homedir(). Profile state and ownership: - A malformed `projects` value skips only folder trust (new warning code trust-refused); onboarding and shared keys still apply. - The ownership marker stores only host-local facts (account, runtime, distro). The execution host id is the caller's view of the host, so it stays in the in-memory descriptor and is not compared. Prompt history interruption paths: - With no cursor yet, a retained copy starts past the bytes it shares with the default history, so an interrupted share no longer replays the whole history. - A retained name for the shared file itself is removed with its cursor instead of lingering until a later scrub makes it look new. - The Windows link record is read three-state: unreadable stops the share instead of reading as "no link". If the record cannot be written after linking, the fresh link is undone. - An unreadable retained copy is reported and no longer blocks linking. * build(cli): list the new Claude hook modules in the CLI project hook-service.ts and hook-settings.ts are compiled into the packaged CLI project, which lists every file explicitly. The statusline policy and profile destination modules they now import were missing, so the CLI typecheck failed with TS6307. The CLI still loads hook-service through the existing managed-agent-hook-controls build entry, which bundles both modules; neither imports electron. * fix(claude): close review round 3 regressions in profile setup - A profile whose hooks hold only Orca's entries and that sharing never recorded is no longer treated as a user edit, so the user's first own hook in ~/.claude reaches it (for example when the profile was set up before ~/.claude had any hooks). - A retained prompt-history file is removed as a second name for the shared file only when the default history does not itself link to it; otherwise it holds the only copy and is kept. - Default-home checks compare file identity: the profile hook destination check uses device and inode, and the profile/default separation check resolves on-disk case, so a case-only alias of ~/.claude is refused on case-insensitive filesystems. - A test pins that an unreadable leftover session tree no longer blocks linking. * fix(claude): let shared keys leave a profile when ~/.claude drops them QA found that removing a setting from ~/.claude never reached a managed account: deleting the whole `hooks` block left the user's hook running there. Only statusLine followed the default away. Every shared key now follows the same rule through the existing per-key ledger: when a key disappears from ~/.claude/settings.json (or mcpServers/theme from the personal state file), it is removed from the profile if the profile still holds exactly what Orca last shared. A value changed inside the account is kept. Keys Orca never shared, including denylisted ones, are never touched. Deleting the whole hooks block removes the user's shared hooks and keeps Orca's own entries. A missing source counts as empty; an unreadable source removes nothing. * fix(claude): share personal rules, themes, workflows and keybindings into account profiles A managed account launches Claude with its own config folder, so user-level rules/, custom themes/ (which a shared `custom:<slug>` theme points at), personal workflows/ and keybindings.json silently stopped applying. Link the three directories like skills and commands, and copy keybindings.json with the same edit-preserving ledger as CLAUDE.md. routines/ stays unshared: routines belong to the claude.ai account and the folder holds per-run state. * fix(claude): import the personal CLAUDE.md into account profiles instead of copying it Claude also loads ~/.claude/CLAUDE.md as a parent folder's memory for any project under home, so a copied account CLAUDE.md made every such session read the user's instructions twice (checked live with Claude 2.1.288). An @~/.claude/CLAUDE.md import resolves to the same real file, which Claude loads once from home, from projects under home and from folders outside it. * refactor(claude): simplify account profile setup toward the prior art - Windows keeps each account's history private; drop the hardlink, link record and conflict-copy machinery that only Windows reached. - Share hooks and statusLine as ordinary settings keys: Orca writes the same entries into every folder, so the installer finds them present. Drops the Orca-entry carve-out, the per-profile statusline follow logic and its marker. - Unreadable ledger is just an empty ledger. - Share from the user's own CLAUDE_CONFIG_DIR when they set one (marked so Orca's injected value is never mistaken for it), and refuse a profile at or around it. - Pin the one canonical profile path spelling in a test. * fix(claude-accounts): dedupe merged prompt history, drop drained copies, link setup folders by path - Prompt-history drain appends only lines the shared file lacks, so a purge never re-adds lines. - A set-aside history copy whose saved offset reaches its end is deleted on the next run. - Setup folders link to the default home's own entry, not its resolved target. - The profile gate and folder creation run once, in provisionClaudeAccountProfile. - installHooks receives only configDir; drop a duplicate test key that fails CI. * fix(claude-accounts): record installed hooks as Orca-shared; skip symlink tests on Windows After Orca installs its hooks into an account, record the account's hooks in the settings ledger so a later run can still bring the user's own hooks in. Tests that create real symlinks now skip on Windows. --------- Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
This commit is contained in:
co-authored by
Jinwoo-H
parent
55c86ceb91
commit
dff65d55a3
@@ -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",
|
||||
|
||||
@@ -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<typeof fs>()
|
||||
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([])
|
||||
})
|
||||
})
|
||||
@@ -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<ClaudeProfileReport> {
|
||||
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
|
||||
}
|
||||
@@ -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' } })
|
||||
})
|
||||
})
|
||||
@@ -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<T> =
|
||||
| { kind: 'present'; value: T }
|
||||
| { kind: 'absent' }
|
||||
| { kind: 'unavailable'; error: unknown }
|
||||
|
||||
function isProfileObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
export function readClaudeProfileObject(file: string): ClaudeProfileRead<Record<string, unknown>> {
|
||||
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')
|
||||
}
|
||||
@@ -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<string>): 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'
|
||||
}
|
||||
@@ -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<typeof fs>()
|
||||
return { ...actual, symlinkSync: vi.fn(actual.symlinkSync) }
|
||||
})
|
||||
vi.mock('../codex-accounts/fs-utils', async (original) => {
|
||||
const actual = await original<typeof FsUtils>()
|
||||
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<string, unknown> => 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')
|
||||
})
|
||||
})
|
||||
@@ -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<string, unknown>): Record<string, unknown> {
|
||||
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<string, unknown> = 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<ClaudeProfileSurfaceOutcome> {
|
||||
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<string, string> = {}
|
||||
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<ClaudeProfileReport> {
|
||||
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
|
||||
}
|
||||
@@ -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<Record<ClaudeProfileSurface, ClaudeProfileSurfaceOutcome>>
|
||||
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<ClaudeProfileSurfaceOutcome>
|
||||
): Promise<void> {
|
||||
try {
|
||||
report.surfaces[surface] = await operation()
|
||||
} catch (error) {
|
||||
report.surfaces[surface] = 'failed'
|
||||
warnClaudeProfile(report, surface, error)
|
||||
}
|
||||
}
|
||||
@@ -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<typeof Os>()),
|
||||
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' })
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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<ClaudeProfileSetupReport> {
|
||||
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 }
|
||||
}
|
||||
@@ -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<string, string>
|
||||
keys: Record<string, Record<string, string>>
|
||||
}
|
||||
|
||||
function stringEntries(value: unknown): Record<string, string> {
|
||||
const entries: Record<string, string> = {}
|
||||
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<typeof lstatSync> | 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<string, unknown>,
|
||||
desired: Record<string, unknown>,
|
||||
written: Record<string, string>
|
||||
): 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<string, unknown>,
|
||||
desired: Record<string, unknown>,
|
||||
written: Record<string, string>
|
||||
): 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
|
||||
}
|
||||
@@ -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<string, Promise<void>>()
|
||||
const updateQueueByConfigFile = new Map<string, Promise<void>>()
|
||||
|
||||
function pathApi(style: ClaudeTrustPathStyle): typeof posix {
|
||||
return style === 'win32' ? win32 : posix
|
||||
@@ -75,7 +75,7 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
export type ClaudeFolderTrustChange =
|
||||
export type ClaudeGlobalConfigChange =
|
||||
| { kind: 'unchanged' }
|
||||
| { kind: 'refuse' }
|
||||
| { kind: 'changed'; config: Record<string, unknown> }
|
||||
@@ -83,7 +83,7 @@ export type ClaudeFolderTrustChange =
|
||||
export function applyClaudeFolderTrust(
|
||||
config: Record<string, unknown>,
|
||||
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<string, unkn
|
||||
renameFileWithWindowsRetry(replacement.path, replacement.target)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets `projects[<folder>].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[<folder>].hasTrustDialogAccepted` in Claude's global config. */
|
||||
export async function grantClaudeFolderTrust(args: {
|
||||
configFile: string
|
||||
folderKeys: readonly string[]
|
||||
}): Promise<ClaudeFolderTrustOutcome> {
|
||||
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<ClaudeFolderTrustOutcome, 'granted'>
|
||||
| '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<string, unknown>) => ClaudeGlobalConfigChange
|
||||
): Promise<ClaudeGlobalConfigUpdateOutcome> {
|
||||
return runKeyedSerializedOperation(updateQueueByConfigFile, configFile, () =>
|
||||
updateClaudeGlobalConfigNow({ configFile, change })
|
||||
)
|
||||
}
|
||||
|
||||
async function grantClaudeFolderTrustNow(args: {
|
||||
async function updateClaudeGlobalConfigNow(args: {
|
||||
configFile: string
|
||||
folderKeys: readonly string[]
|
||||
}): Promise<ClaudeFolderTrustOutcome> {
|
||||
change: (config: Record<string, unknown>) => ClaudeGlobalConfigChange
|
||||
}): Promise<ClaudeGlobalConfigUpdateOutcome> {
|
||||
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(() => {})
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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<typeof Os>()),
|
||||
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<string, unknown> =>
|
||||
JSON.parse(readFileSync(join(dir, 'settings.json'), 'utf8'))
|
||||
const edit = (dir: string, change: (value: Record<string, unknown>) => 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<typeof fixture>) {
|
||||
const hook = (command: string) => ({ matcher: '', hooks: [{ type: 'command', command }] })
|
||||
const stopOf = (value: Record<string, unknown>): 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<typeof fixture>) => {
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user