diff --git a/packages/server/src/server/agent/providers/claude/agent.test.ts b/packages/server/src/server/agent/providers/claude/agent.test.ts index 96022d851d..c908e17cf5 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -428,7 +428,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => { logger, resolveBinary: async () => "/test/claude/bin", resolveVersion: async () => "2.1.219", - configDir: emptyConfigDir, + runtimeSettings: { env: { CLAUDE_CONFIG_DIR: emptyConfigDir } }, }); const { models } = await client.fetchCatalog({ scope: "workspace", @@ -475,7 +475,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => { resolveVersion: async () => { throw new Error("unrecognized version output"); }, - configDir: emptyConfigDir, + runtimeSettings: { env: { CLAUDE_CONFIG_DIR: emptyConfigDir } }, }); const { models } = await client.fetchCatalog({ scope: "workspace", @@ -497,7 +497,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => { logger, resolveBinary: async () => "/test/claude/bin", resolveVersion: async () => "2.1.219", - configDir: emptyConfigDir, + runtimeSettings: { env: { CLAUDE_CONFIG_DIR: emptyConfigDir } }, }); const { models } = await client.fetchCatalog({ scope: "workspace", diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index b4b516fe1e..04fef2b1f6 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -2,7 +2,6 @@ import type { ChildProcess } from "node:child_process"; import { randomUUID } from "node:crypto"; import fs from "node:fs"; import { promises } from "node:fs"; -import os from "node:os"; import path from "node:path"; import { type AgentDefinition, @@ -83,7 +82,7 @@ import { type ClaudeRewindSdk, } from "./rewind.js"; import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js"; -import { claudeProjectDirSync } from "./project-dir.js"; +import { claudeConfigDir, claudeProjectDirSync } from "./project-dir.js"; import { THINKING_APPLIES_NEXT_TURN_NOTICE } from "../../provider-notices.js"; import { isProviderImageMarkdown, @@ -409,7 +408,6 @@ interface ClaudeAgentClientOptions { queryFactory?: ClaudeQueryFactory; resolveBinary?: () => Promise; resolveVersion?: (signal?: AbortSignal) => Promise; - configDir?: string; rewindSdk?: ClaudeRewindSdk; } @@ -1506,7 +1504,6 @@ export class ClaudeAgentClient implements AgentClient { private readonly queryFactory?: ClaudeQueryFactory; private readonly resolveBinary: () => Promise; private readonly resolveVersion: (signal?: AbortSignal) => Promise; - private readonly configDir?: string; private readonly rewindSdk: ClaudeRewindSdk; constructor(options: ClaudeAgentClientOptions) { @@ -1518,7 +1515,6 @@ export class ClaudeAgentClient implements AgentClient { this.resolveVersion = options.resolveVersion ?? ((signal) => resolveClaudeCodeVersion(this.runtimeSettings, signal)); - this.configDir = options.configDir; this.rewindSdk = options.rewindSdk ?? realClaudeRewindSdk; } @@ -1592,12 +1588,11 @@ export class ClaudeAgentClient implements AgentClient { } catch (error) { this.logger.warn({ err: error }, "Failed to resolve Claude Code version for model catalog"); } + const env = this.buildProviderEnv(); const models = await runProviderRefreshActivity(context, "settings", () => - getClaudeModelsWithSettings(this.logger, this.configDir, claudeCodeVersion), - ); - const modeCatalog = claudeModeCatalog( - createProviderEnv({ baseEnv: process.env, runtimeSettings: this.runtimeSettings }), + getClaudeModelsWithSettings(this.logger, claudeConfigDir(env), claudeCodeVersion), ); + const modeCatalog = claudeModeCatalog(env); return { models, ...modeCatalog, @@ -1605,12 +1600,15 @@ export class ClaudeAgentClient implements AgentClient { } async resolveDefaultModeId({ env: launchEnv }: ResolveAgentDefaultModeInput): Promise { - const env = createProviderEnv({ + return claudeModeCatalog(this.buildProviderEnv(launchEnv)).defaultModeId; + } + + private buildProviderEnv(launchEnv?: Record): NodeJS.ProcessEnv { + return createProviderEnv({ baseEnv: process.env, runtimeSettings: this.runtimeSettings, overlays: [launchEnv], }); - return claudeModeCatalog(env).defaultModeId; } async listFeatures(config: AgentSessionConfig): Promise { @@ -1624,7 +1622,7 @@ export class ClaudeAgentClient implements AgentClient { async listImportableSessions( options?: ListImportableSessionsOptions, ): Promise { - const configDir = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude"); + const configDir = claudeConfigDir(this.buildProviderEnv()); const sessionsRoot = options?.cwd ? claudeProjectDirSync(options.cwd, { configDir }) : path.join(configDir, "projects"); @@ -5051,7 +5049,7 @@ class ClaudeAgentSession implements AgentSession { private resolveHistoryPath(sessionId: string): string | null { const cwd = this.config.cwd; if (!cwd) return null; - const configDir = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude"); + const configDir = claudeConfigDir(this.buildSdkEnv()); const candidates = [cwd]; try { const realCwd = fs.realpathSync(cwd); diff --git a/packages/server/src/server/agent/providers/claude/models.test.ts b/packages/server/src/server/agent/providers/claude/models.test.ts index 5a10980fbf..af725ca047 100644 --- a/packages/server/src/server/agent/providers/claude/models.test.ts +++ b/packages/server/src/server/agent/providers/claude/models.test.ts @@ -274,6 +274,25 @@ describe("ClaudeAgentClient.fetchCatalog", () => { expect(models.some((model) => model.id === "claude-fable-5-1")).toBe(true); }); + it("reads settings.json from the provider's own CLAUDE_CONFIG_DIR", async () => { + const daemonConfigDir = await createClaudeConfigDir({}); + const providerConfigDir = await createClaudeConfigDir({ model: "glm-5.1" }); + vi.stubEnv("CLAUDE_CONFIG_DIR", daemonConfigDir); + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + resolveVersion: async () => "2.1.280", + runtimeSettings: { env: { CLAUDE_CONFIG_DIR: providerConfigDir } }, + }); + + const { models } = await client.fetchCatalog({ + scope: "workspace", + cwd: os.tmpdir(), + force: true, + }); + + expect(models.map((model) => model.id)).toContain("glm-5.1"); + }); + it("falls back to hardcoded models when settings.json is missing", async () => { const configDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-models-")); createdClaudeConfigDirs.push(configDir); diff --git a/packages/server/src/server/agent/providers/claude/models.ts b/packages/server/src/server/agent/providers/claude/models.ts index 603f21c92c..6b341fa9ea 100644 --- a/packages/server/src/server/agent/providers/claude/models.ts +++ b/packages/server/src/server/agent/providers/claude/models.ts @@ -1,5 +1,4 @@ import * as fs from "node:fs/promises"; -import * as os from "node:os"; import * as path from "node:path"; import type { Logger } from "pino"; @@ -51,7 +50,7 @@ export function findClaudeModel( export async function getClaudeModelsWithSettings( logger: Logger, - configDir?: string, + configDir: string, claudeCodeVersion?: string, ): Promise { const hardcodedModels = getClaudeModels(claudeCodeVersion); @@ -79,9 +78,9 @@ export async function getClaudeModelsWithSettings( async function readClaudeSettingsModels( logger: Logger, - configDir?: string, + configDir: string, ): Promise { - const settingsPath = path.join(resolveClaudeConfigDir(configDir), "settings.json"); + const settingsPath = path.join(configDir, "settings.json"); let parsed: unknown; try { @@ -116,10 +115,6 @@ async function readClaudeSettingsModels( return models; } -function resolveClaudeConfigDir(configDir?: string): string { - return configDir ?? process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude"); -} - function addSettingsModel( models: AgentModelDefinition[], value: unknown, diff --git a/packages/server/src/server/agent/providers/claude/project-dir.ts b/packages/server/src/server/agent/providers/claude/project-dir.ts index 6a89711eea..ccac0841de 100644 --- a/packages/server/src/server/agent/providers/claude/project-dir.ts +++ b/packages/server/src/server/agent/providers/claude/project-dir.ts @@ -66,6 +66,11 @@ function hashSuffix(input: string): string { return Math.abs(hash).toString(36); } +// Claude Code keeps its settings and transcripts here for a process launched with `env`. +export function claudeConfigDir(env: NodeJS.ProcessEnv): string { + return env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude"); +} + function resolveConfigDir(options?: ClaudeProjectDirOptions): string { - return options?.configDir ?? process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude"); + return options?.configDir ?? claudeConfigDir(process.env); } diff --git a/packages/server/src/server/daemon-e2e/claude-provider-config-dir-history.e2e.test.ts b/packages/server/src/server/daemon-e2e/claude-provider-config-dir-history.e2e.test.ts new file mode 100644 index 0000000000..29779fd1ef --- /dev/null +++ b/packages/server/src/server/daemon-e2e/claude-provider-config-dir-history.e2e.test.ts @@ -0,0 +1,137 @@ +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { claudeProjectDirSync } from "../agent/providers/claude/project-dir.js"; +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js"; + +const PROVIDER_ID = "claude-secondary"; +const SESSION_ID = "provider-config-dir-session"; + +function timelineText(entries: ReadonlyArray<{ item: { type: string; text?: string } }>): string { + return entries + .filter( + (entry): entry is { item: { type: "user_message" | "assistant_message"; text: string } } => + entry.item.type === "user_message" || entry.item.type === "assistant_message", + ) + .map((entry) => entry.item.text) + .join("\n"); +} + +describe("daemon E2E - Claude history lives in the provider's CLAUDE_CONFIG_DIR", () => { + let tempRoot: string; + let paseoHomeRoot: string; + let providerConfigDir: string; + let cwd: string; + let prevClaudeConfigDir: string | undefined; + let daemon: TestPaseoDaemon | undefined; + let client: DaemonClient | undefined; + + beforeEach(() => { + tempRoot = mkdtempSync(path.join(tmpdir(), "claude-provider-config-dir-")); + paseoHomeRoot = path.join(tempRoot, "paseo-home"); + providerConfigDir = path.join(tempRoot, "custom-claude-dir"); + cwd = path.join(tempRoot, "repo"); + mkdirSync(paseoHomeRoot, { recursive: true }); + mkdirSync(cwd, { recursive: true }); + + const projectDir = claudeProjectDirSync(cwd, { configDir: providerConfigDir }); + mkdirSync(projectDir, { recursive: true }); + writeFileSync( + path.join(projectDir, `${SESSION_ID}.jsonl`), + [ + { + type: "user", + uuid: "user-uuid-1", + sessionId: SESSION_ID, + cwd, + message: { role: "user", content: "hello from the custom config dir" }, + }, + { + type: "assistant", + sessionId: SESSION_ID, + cwd, + message: { role: "assistant", content: "reply from the custom config dir" }, + }, + ] + .map((entry) => `${JSON.stringify(entry)}\n`) + .join(""), + "utf8", + ); + + // The daemon's own environment does not name the directory; only the provider's env does. + prevClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; + delete process.env.CLAUDE_CONFIG_DIR; + }); + + afterEach(async () => { + await client?.close().catch(() => undefined); + await daemon?.close().catch(() => undefined); + client = undefined; + daemon = undefined; + rmSync(tempRoot, { recursive: true, force: true }); + if (prevClaudeConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR; + } else { + process.env.CLAUDE_CONFIG_DIR = prevClaudeConfigDir; + } + }, 60_000); + + async function startDaemon(): Promise { + daemon = await createTestPaseoDaemon({ + paseoHomeRoot, + cleanup: false, + providerOverrides: { + [PROVIDER_ID]: { + extends: "claude", + label: "Claude (secondary)", + env: { CLAUDE_CONFIG_DIR: providerConfigDir }, + }, + }, + }); + client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + await client.connect(); + await client.fetchAgents({ subscribe: {} }); + return client; + } + + async function stopDaemon(): Promise { + await client?.close(); + await daemon?.close(); + client = undefined; + daemon = undefined; + } + + test("an agent's timeline survives a daemon restart", async () => { + const firstClient = await startDaemon(); + const agent = await firstClient.importAgent({ + provider: PROVIDER_ID, + sessionId: SESSION_ID, + cwd, + }); + await stopDaemon(); + + const restartedClient = await startDaemon(); + const timeline = await restartedClient.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 0, + projection: "canonical", + }); + + const text = timelineText(timeline.entries); + expect(text).toContain("hello from the custom config dir"); + expect(text).toContain("reply from the custom config dir"); + }, 60_000); + + test("the import list finds the provider's sessions", async () => { + const daemonClient = await startDaemon(); + const recent = await daemonClient.fetchRecentProviderSessions({ + cwd, + providers: [PROVIDER_ID], + }); + + expect(recent.entries.map((entry) => entry.providerHandleId)).toContain(SESSION_ID); + }, 60_000); +});