Skip to content

Commit 92302bb

Browse files
13rac1claude
andcommitted
fix: mount host ~/.claude directly for OAuth token persistence
Instead of copying credentials to a session-specific directory (where token refreshes would be lost), mount the host's ~/.claude directory directly. Podman bind mounts persist changes automatically, so OAuth token refreshes now persist to the host immediately. Changes: - Rename startDetached param from claudeDir to hostClaudeDir - Remove credential copying logic from claude-code.ts - Update tests to verify mount instead of copy behavior 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent e477e03 commit 92302bb

5 files changed

Lines changed: 30 additions & 59 deletions

File tree

src/claude-code.test.ts

Lines changed: 11 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -279,10 +279,9 @@ describe("register", () => {
279279
expect(mockPodmanRunner.startDetached).toHaveBeenCalled();
280280
});
281281

282-
it("copies OAuth credentials to session directory when credentials file exists", async () => {
282+
it("mounts host ~/.claude directly when OAuth credentials exist", async () => {
283283
// Mock credentials file exists
284284
vi.mocked(fs.access).mockResolvedValue(undefined);
285-
vi.mocked(fs.copyFile).mockResolvedValue(undefined);
286285
delete process.env.ANTHROPIC_API_KEY;
287286

288287
mockPodmanRunner.checkImage.mockResolvedValue(true);
@@ -317,23 +316,18 @@ describe("register", () => {
317316

318317
await toolConfig.execute("test-id", { prompt: "hello" });
319318

320-
// Verify credentials were copied to session directory
321-
expect(fs.copyFile).toHaveBeenCalledWith(
322-
expect.stringContaining(".claude/.credentials.json"),
323-
expect.stringContaining("session-test-id/.claude/.credentials.json")
324-
);
325-
// Verify apiKey was NOT passed (since we're using OAuth)
319+
// Verify host ~/.claude is mounted directly (not copied)
326320
expect(mockPodmanRunner.startDetached).toHaveBeenCalledWith(
327321
expect.objectContaining({
322+
hostClaudeDir: expect.stringContaining(".claude"),
328323
apiKey: undefined,
329324
})
330325
);
331326
});
332327

333-
it("copies OAuth credentials on both first and resumed session jobs", async () => {
328+
it("mounts host ~/.claude on both first and resumed session jobs", async () => {
334329
// Mock credentials file exists
335330
vi.mocked(fs.access).mockResolvedValue(undefined);
336-
vi.mocked(fs.copyFile).mockResolvedValue(undefined);
337331
delete process.env.ANTHROPIC_API_KEY;
338332

339333
mockPodmanRunner.checkImage.mockResolvedValue(true);
@@ -370,14 +364,14 @@ describe("register", () => {
370364

371365
await toolConfig.execute("first-job", { prompt: "first task", session_id: "resume-test" });
372366

373-
// Verify credentials were copied
374-
expect(fs.copyFile).toHaveBeenCalledWith(
375-
expect.stringContaining(".claude/.credentials.json"),
376-
expect.stringContaining("resume-test/.claude/.credentials.json")
367+
// Verify host ~/.claude is mounted directly
368+
expect(mockPodmanRunner.startDetached).toHaveBeenCalledWith(
369+
expect.objectContaining({
370+
hostClaudeDir: expect.stringContaining(".claude"),
371+
})
377372
);
378373

379374
// Clear for second job
380-
vi.mocked(fs.copyFile).mockClear();
381375
vi.mocked(mockPodmanRunner.startDetached).mockClear();
382376

383377
// Second job - resumed session (now has claudeSessionId)
@@ -394,13 +388,10 @@ describe("register", () => {
394388

395389
await toolConfig.execute("second-job", { prompt: "second task", session_id: "resume-test" });
396390

397-
// Verify credentials are STILL copied on resumed session
398-
expect(fs.copyFile).toHaveBeenCalledWith(
399-
expect.stringContaining(".claude/.credentials.json"),
400-
expect.stringContaining("resume-test/.claude/.credentials.json")
401-
);
391+
// Verify host ~/.claude is STILL mounted on resumed session (token refreshes persist)
402392
expect(mockPodmanRunner.startDetached).toHaveBeenCalledWith(
403393
expect.objectContaining({
394+
hostClaudeDir: expect.stringContaining(".claude"),
404395
resumeSessionId: "claude-session-abc123", // Should have session ID this time
405396
})
406397
);

src/claude-code.ts

Lines changed: 7 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -139,11 +139,6 @@ export default function register(api: PluginApi): void {
139139
return { apiKey, hasCredsFile };
140140
}
141141

142-
// Get path to host credentials file
143-
function getHostCredsPath(): string {
144-
return path.join(homedir(), ".claude", ".credentials.json");
145-
}
146-
147142
// Find a job by ID, searching all sessions if session_id not provided
148143
async function findJob(
149144
jobId: string,
@@ -367,7 +362,7 @@ export default function register(api: PluginApi): void {
367362
const sessionKey = (params.session_id as string | undefined) ?? `session-${id}`;
368363

369364
// Check authentication
370-
const { apiKey, hasCredsFile } = await getAuth();
365+
const { apiKey } = await getAuth();
371366

372367
// Verify container image exists
373368
const imageExists = await podmanRunner.checkImage();
@@ -390,20 +385,14 @@ export default function register(api: PluginApi): void {
390385
}
391386

392387
// Get paths for volume mounts
393-
const claudeDir = `${config.sessionsDir.replace("~", process.env.HOME ?? "")}/${sessionKey}/.claude`;
388+
// Mount host ~/.claude directly so OAuth token refreshes persist
389+
const hostClaudeDir = path.join(homedir(), ".claude");
394390
const workspaceDir = sessionManager.workspaceDir(sessionKey);
395391

396-
// Copy credentials to session directory (with --userns=keep-id, we can write to the dir)
397-
if (hasCredsFile) {
398-
const hostCredsPath = getHostCredsPath();
399-
const sessionCredsPath = path.join(claudeDir, ".credentials.json");
400-
console.log(
401-
`[claude-code] Copying credentials from ${hostCredsPath} to ${sessionCredsPath}`
402-
);
403-
await fs.copyFile(hostCredsPath, sessionCredsPath);
404-
}
392+
// Ensure ~/.claude directory exists
393+
await fs.mkdir(hostClaudeDir, { recursive: true });
405394

406-
console.log(`[claude-code] Volume mounts: claudeDir=${claudeDir}`);
395+
console.log(`[claude-code] Volume mounts: hostClaudeDir=${hostClaudeDir}`);
407396

408397
// Create job record
409398
const containerName = podmanRunner.containerNameFromSessionKey(sessionKey);
@@ -414,7 +403,7 @@ export default function register(api: PluginApi): void {
414403
await podmanRunner.startDetached({
415404
sessionKey,
416405
prompt,
417-
claudeDir,
406+
hostClaudeDir,
418407
workspaceDir,
419408
resumeSessionId: session.claudeSessionId ?? undefined,
420409
apiKey,

src/podman-runner.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -366,7 +366,7 @@ describe("PodmanRunner", () => {
366366
const promise = runner.startDetached({
367367
sessionKey: "test-session",
368368
prompt: "Hello world",
369-
claudeDir: "/path/.claude",
369+
hostClaudeDir: "/path/.claude",
370370
workspaceDir: "/path/workspace",
371371
apiKey: "sk-test",
372372
});
@@ -408,7 +408,7 @@ describe("PodmanRunner", () => {
408408
const promise = runner.startDetached({
409409
sessionKey: "test",
410410
prompt: "test",
411-
claudeDir: "/path/.claude",
411+
hostClaudeDir: "/path/.claude",
412412
workspaceDir: "/path/workspace",
413413
});
414414

@@ -439,7 +439,7 @@ describe("PodmanRunner", () => {
439439
const promise = runner.startDetached({
440440
sessionKey: "test",
441441
prompt: "test",
442-
claudeDir: "/path/.claude",
442+
hostClaudeDir: "/path/.claude",
443443
workspaceDir: "/path/workspace",
444444
});
445445

@@ -471,7 +471,7 @@ describe("PodmanRunner", () => {
471471
const promise = runner.startDetached({
472472
sessionKey: "userns-test",
473473
prompt: "test userns",
474-
claudeDir: "/path/.claude",
474+
hostClaudeDir: "/path/.claude",
475475
workspaceDir: "/path/workspace",
476476
});
477477

@@ -512,7 +512,7 @@ describe("PodmanRunner", () => {
512512
const promise = runner.startDetached({
513513
sessionKey: "mount-test",
514514
prompt: "test mounts",
515-
claudeDir: "/path/.claude",
515+
hostClaudeDir: "/path/.claude",
516516
workspaceDir: "/path/workspace",
517517
});
518518

src/podman-runner.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -283,12 +283,12 @@ export class PodmanRunner {
283283

284284
/**
285285
* Start a container in detached mode. Returns immediately with container ID.
286-
* Credentials should be copied to claudeDir before calling this method.
286+
* Mounts hostClaudeDir directly so OAuth token refreshes persist to host.
287287
*/
288288
async startDetached(params: {
289289
sessionKey: string;
290290
prompt: string;
291-
claudeDir: string;
291+
hostClaudeDir: string;
292292
workspaceDir: string;
293293
resumeSessionId?: string;
294294
apiKey?: string;
@@ -329,7 +329,7 @@ export class PodmanRunner {
329329
"--tmpfs",
330330
"/tmp:rw,noexec,nosuid,size=64m",
331331
"-v",
332-
`${params.claudeDir}:/home/claude/.claude:rw`,
332+
`${params.hostClaudeDir}:/home/claude/.claude:rw`,
333333
"-v",
334334
`${params.workspaceDir}:/workspace:rw`
335335
);

src/test-harness.ts

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
import { PodmanRunner } from "./podman-runner.js";
1111
import { SessionManager } from "./session-manager.js";
1212
import { execSync } from "node:child_process";
13-
import * as fs from "node:fs/promises";
1413
import * as path from "node:path";
1514
import { homedir } from "node:os";
1615

@@ -86,21 +85,13 @@ async function main(): Promise<void> {
8685
console.log("✓ Session created:", session);
8786
console.log("");
8887

89-
// Get paths (claudeDir is private, so compute it directly)
90-
const claudeDir = path.join(sessionConfig.sessionsDir, sessionKey, ".claude");
88+
// Get paths - mount host's ~/.claude directly so OAuth token refreshes persist
89+
const hostClaudeDir = path.join(homedir(), ".claude");
9190
const workspaceDir = sessionManager.workspaceDir(sessionKey);
92-
console.log("Claude dir:", claudeDir);
91+
console.log("Host claude dir:", hostClaudeDir);
9392
console.log("Workspace dir:", workspaceDir);
9493
console.log("");
9594

96-
// Copy credentials to session directory (with --userns=keep-id, we can write to the dir)
97-
if (credentials) {
98-
const hostCredsPath = path.join(homedir(), ".claude", ".credentials.json");
99-
const sessionCredsPath = path.join(claudeDir, ".credentials.json");
100-
console.log(`Copying credentials from ${hostCredsPath} to ${sessionCredsPath}`);
101-
await fs.copyFile(hostCredsPath, sessionCredsPath);
102-
}
103-
10495
// Run Claude Code (async)
10596
const prompt = "Say 'Hello from test harness!' and nothing else";
10697
console.log("=== Running Claude Code (async) ===");
@@ -114,7 +105,7 @@ async function main(): Promise<void> {
114105
const { containerName } = await podmanRunner.startDetached({
115106
sessionKey,
116107
prompt,
117-
claudeDir,
108+
hostClaudeDir,
118109
workspaceDir,
119110
resumeSessionId: session.claudeSessionId ?? undefined,
120111
apiKey: credentials ? undefined : process.env.ANTHROPIC_API_KEY,

0 commit comments

Comments
 (0)