Skip to content

Commit 260dfd7

Browse files
rafiki270claude
andcommitted
refactor: remove symlink farm, use real HOME for Claude
The full-home symlink mirror was fragile — hundreds of symlinks, cross-device failures, race conditions on cleanup. Replace with the right model per provider: - Claude (-p flag): runs with real HOME. No local session state is written in print mode; auth lives naturally in ~/.claude; server-side UUIDs mean parallel sessions don't conflict. Isolated home was never necessary. - Gemini/Codex: keep the minimal per-project persistent home — small, stable, not a symlink farm. Temp fallback kept but reverted to the simple form (provider dir + .gitconfig + .ssh only). - Mistral: already correct — sets VIBE_HOME only, HOME untouched. setupIsolatedHome is now explicitly scoped to providers that store session state locally and need parallel-session isolation. Its docstring calls this out. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 2b990c7 commit 260dfd7

File tree

2 files changed

+29
-96
lines changed

2 files changed

+29
-96
lines changed

src/providers/claude.ts

Lines changed: 6 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
*/
55

66
import { spawn } from 'node:child_process';
7-
import { writeFileSync, unlinkSync, existsSync, rmSync } from 'node:fs';
7+
import { writeFileSync, unlinkSync, existsSync } from 'node:fs';
88
import { tmpdir } from 'node:os';
99
import { join } from 'node:path';
1010
import {
@@ -232,12 +232,10 @@ export class ClaudeProvider extends BaseAIProvider {
232232
}
233233
}
234234

235-
// Set up isolated HOME
236-
// ~/.claude.json lives at HOME root (not inside ~/.claude/) and holds onboarding/machine state.
237-
// Without it, Claude Code shows a toolchain/onboarding prompt on every invocation, which hangs
238-
// since stdin is closed.
239-
const isolatedHome = this.setupIsolatedHome('.claude', ['config.json', '.credentials.json', 'settings.json'], undefined, ['.claude.json']);
240-
235+
// Claude runs with the real HOME — no isolated home needed.
236+
// In -p (print) mode it does not write interactive history, and auth/config
237+
// files are naturally present in the real home. Each session has a server-side
238+
// UUID so parallel sessions don't conflict on local state.
241239
return new Promise((resolve, reject) => {
242240
const startTime = Date.now();
243241
let stdout = '';
@@ -257,10 +255,7 @@ export class ClaudeProvider extends BaseAIProvider {
257255
const child = spawn(command, {
258256
shell: true,
259257
cwd,
260-
env: this.getSanitizedCliEnv({
261-
HOME: isolatedHome,
262-
...proxyOverrides,
263-
}),
258+
env: this.getSanitizedCliEnv({ ...proxyOverrides }),
264259
stdio: ['pipe', 'pipe', 'pipe'],
265260
});
266261

@@ -348,13 +343,6 @@ export class ClaudeProvider extends BaseAIProvider {
348343
clearTimeout(activityTimer);
349344
const duration = Date.now() - startTime;
350345

351-
// Cleanup isolated home
352-
try {
353-
rmSync(isolatedHome, { recursive: true, force: true });
354-
} catch {
355-
// Ignore cleanup errors
356-
}
357-
358346
const outputStr = (stdout + '\n' + stderr).toLowerCase();
359347
if (code !== 0 && resumeSessionId && (
360348
!outputStr.trim() || // No output = session file missing (e.g. isolated home was cleaned up)
@@ -382,13 +370,6 @@ export class ClaudeProvider extends BaseAIProvider {
382370
clearTimeout(activityTimer);
383371
const duration = Date.now() - startTime;
384372

385-
// Cleanup isolated home
386-
try {
387-
rmSync(isolatedHome, { recursive: true, force: true });
388-
} catch {
389-
// Ignore cleanup errors
390-
}
391-
392373
resolve({
393374
success: false,
394375
exitCode: 1,

src/providers/interface.ts

Lines changed: 23 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { existsSync, mkdirSync, readdirSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs';
1+
import { existsSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs';
22
import { homedir, tmpdir } from 'node:os';
33
import { join, dirname } from 'node:path';
44
import { randomUUID } from 'node:crypto';
@@ -363,24 +363,21 @@ export abstract class BaseAIProvider implements IAIProvider {
363363
}
364364

365365
/**
366-
* Set up an isolated home directory for a provider CLI.
366+
* Set up a minimal isolated home directory for a provider CLI.
367367
*
368-
* For temp homes (baseDir not provided): mirrors the entire real home top-level
369-
* into the isolated dir via symlinks, then replaces only `providerDir` with a real
370-
* isolated directory containing auth-file symlinks. This gives the spawned process
371-
* access to all user dotfiles, tools, configs, and credentials without needing an
372-
* explicit allowlist, while keeping provider session state isolated between parallel
373-
* invocations.
368+
* Only used by providers that store session state locally and need isolation
369+
* between parallel invocations (Gemini, Codex). Providers that don't write
370+
* local session state (Claude with -p flag, Mistral via VIBE_HOME) should
371+
* NOT use this — they should run with the real HOME or a targeted env var.
374372
*
375-
* For persistent homes (baseDir provided, e.g. Gemini's per-project home): only
376-
* creates the provider dir + auth symlinks, plus a small set of essential files.
377-
* We don't full-mirror into persistent homes because they live inside project dirs
378-
* and should not be filled with real-home symlinks.
373+
* Creates a minimal dir with:
374+
* - an isolated providerDir containing only auth-file symlinks
375+
* - symlinks for .gitconfig, .ssh, and any extra rootFiles
379376
*
380-
* @param providerDir Provider-specific config/state dir (e.g. '.claude', '.config/gcloud')
377+
* @param providerDir Provider-specific config/state dir (e.g. '.gemini', '.config/gcloud')
381378
* @param authFiles Auth/config filenames to symlink into the isolated providerDir
382-
* @param baseDir Optional pre-existing dir to use instead of creating a temp one
383-
* @param rootFiles Extra HOME-root files to symlink for persistent homes (ignored for temp homes)
379+
* @param baseDir Optional pre-existing dir to use (e.g. per-project persistent home)
380+
* @param rootFiles Extra HOME-root files to symlink (e.g. ['.npmrc'])
384381
*/
385382
protected setupIsolatedHome(providerDir: string, authFiles: string[], baseDir?: string, rootFiles?: string[]): string {
386383
const uuid = randomUUID();
@@ -392,54 +389,13 @@ export abstract class BaseAIProvider implements IAIProvider {
392389
mkdirSync(isolatedHome, { recursive: true });
393390
}
394391

395-
// ── Temp home: mirror the real home ──────────────────────────────────────
396-
// Symlink every top-level real-home entry except the provider dir (which we
397-
// create as a real isolated dir below). For nested provider dirs like
398-
// '.config/gcloud', the parent ('.config') is excluded from the mirror and
399-
// rebuilt with its own contents minus the specific subdir.
400-
if (!baseDir) {
401-
const topProviderDir = providerDir.split('/')[0];
402-
403-
try {
404-
for (const entry of readdirSync(realHome)) {
405-
if (entry === topProviderDir) continue; // handled separately below
406-
const src = join(realHome, entry);
407-
const dest = join(isolatedHome, entry);
408-
if (!existsSync(dest)) {
409-
try { symlinkSync(src, dest); } catch { /* best effort */ }
410-
}
411-
}
412-
} catch { /* best effort — don't abort if real home listing fails */ }
413-
414-
// For nested paths (e.g. '.config/gcloud'): create the parent dir as a real
415-
// dir and symlink everything from the real parent except the specific subdir.
416-
const isNested = providerDir.includes('/');
417-
if (isNested) {
418-
const subDir = providerDir.slice(topProviderDir.length + 1);
419-
const realParentPath = join(realHome, topProviderDir);
420-
const isolatedParentPath = join(isolatedHome, topProviderDir);
421-
mkdirSync(isolatedParentPath, { recursive: true });
422-
if (existsSync(realParentPath)) {
423-
try {
424-
for (const entry of readdirSync(realParentPath)) {
425-
if (entry === subDir) continue;
426-
const src = join(realParentPath, entry);
427-
const dest = join(isolatedParentPath, entry);
428-
if (!existsSync(dest)) {
429-
try { symlinkSync(src, dest); } catch { /* best effort */ }
430-
}
431-
}
432-
} catch { /* best effort */ }
433-
}
434-
}
435-
}
436-
437-
// ── Create isolated provider dir with auth-file symlinks ─────────────────
392+
// Create isolated provider dir with auth-file symlinks
438393
const realProviderPath = join(realHome, providerDir);
439394
const isolatedProviderPath = join(isolatedHome, providerDir);
440-
mkdirSync(isolatedProviderPath, { recursive: true });
441395

442396
if (existsSync(realProviderPath)) {
397+
mkdirSync(isolatedProviderPath, { recursive: true });
398+
443399
for (const file of authFiles) {
444400
const src = join(realProviderPath, file);
445401
const dest = join(isolatedProviderPath, file);
@@ -458,18 +414,14 @@ export abstract class BaseAIProvider implements IAIProvider {
458414
}
459415
}
460416

461-
// ── Persistent home: explicit essential symlinks ──────────────────────────
462-
// The full-mirror doesn't apply to persistent homes, so add the minimum
463-
// required files explicitly.
464-
if (baseDir) {
465-
[...(rootFiles ?? []), '.gitconfig', '.ssh'].forEach(file => {
466-
const src = join(realHome, file);
467-
const dest = join(isolatedHome, file);
468-
if (existsSync(src) && !existsSync(dest)) {
469-
try { symlinkSync(src, dest); } catch { /* best effort */ }
470-
}
471-
});
472-
}
417+
// Essential HOME-root symlinks so git and SSH work from the isolated home
418+
[...(rootFiles ?? []), '.gitconfig', '.ssh'].forEach(file => {
419+
const src = join(realHome, file);
420+
const dest = join(isolatedHome, file);
421+
if (existsSync(src) && !existsSync(dest)) {
422+
try { symlinkSync(src, dest); } catch { /* best effort */ }
423+
}
424+
});
473425

474426
} catch (e) {
475427
console.warn(`Failed to set up isolated ${this.name} home: ${e}`);

0 commit comments

Comments
 (0)