Read Claude history from the provider's own config directory (#5437)

A provider that extends claude and sets CLAUDE_CONFIG_DIR in its env
writes transcripts and settings.json under that directory, but the
daemon looked them up under its own CLAUDE_CONFIG_DIR or ~/.claude.
Agents on that provider reopened with an empty timeline after a daemon
restart, their sessions were missing from the import list, and models
from its settings.json were missing from the catalog.

Resolve the config directory from the same environment the provider is
launched with, and drop the test-only configDir client option.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
This commit is contained in:
paseo-bot[bot]
2026-09-26 13:57:27 +02:00
committed by GitHub
parent a272a22d7e
commit 76a9781ba5
6 changed files with 179 additions and 25 deletions
@@ -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",
@@ -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<string>;
resolveVersion?: (signal?: AbortSignal) => Promise<string>;
configDir?: string;
rewindSdk?: ClaudeRewindSdk;
}
@@ -1506,7 +1504,6 @@ export class ClaudeAgentClient implements AgentClient {
private readonly queryFactory?: ClaudeQueryFactory;
private readonly resolveBinary: () => Promise<string>;
private readonly resolveVersion: (signal?: AbortSignal) => Promise<string>;
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<string> {
const env = createProviderEnv({
return claudeModeCatalog(this.buildProviderEnv(launchEnv)).defaultModeId;
}
private buildProviderEnv(launchEnv?: Record<string, string>): NodeJS.ProcessEnv {
return createProviderEnv({
baseEnv: process.env,
runtimeSettings: this.runtimeSettings,
overlays: [launchEnv],
});
return claudeModeCatalog(env).defaultModeId;
}
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
@@ -1624,7 +1622,7 @@ export class ClaudeAgentClient implements AgentClient {
async listImportableSessions(
options?: ListImportableSessionsOptions,
): Promise<ImportableProviderSession[]> {
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);
@@ -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);
@@ -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<AgentModelDefinition[]> {
const hardcodedModels = getClaudeModels(claudeCodeVersion);
@@ -79,9 +78,9 @@ export async function getClaudeModelsWithSettings(
async function readClaudeSettingsModels(
logger: Logger,
configDir?: string,
configDir: string,
): Promise<AgentModelDefinition[]> {
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,
@@ -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);
}
@@ -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<DaemonClient> {
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<void> {
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);
});