fix: scope claude doctor runtime checks

This commit is contained in:
Peter Steinberger
2026-04-29 05:44:36 +01:00
parent b3a8c7146b
commit 9023b120a1
5 changed files with 312 additions and 97 deletions

View File

@@ -37,11 +37,13 @@ Docs: https://docs.openclaw.ai
- Gateway/sessions: add conservative stuck-session recovery that releases only stale session lanes while active embedded runs, reply operations, and lane tasks remain serialized, so queued follow-ups can drain without aborting legitimate long-running turns. Refs #73581, #73655, #73652, #73705, #73647, #73602, #73592, and #73601. Thanks @WS-Q0758, @bryangauvin, @spenceryang1996-dot, @bmilne1981, @mattmcintyre, @Vksh07, and @Spolen23.
- Plugins: cache unchanged plugin manifest loads by file signature, reducing repeated JSON/JSON5 parsing and manifest normalization in bursty startup and runtime registry paths. Refs #73532 and #73647; carries forward #73678. Thanks @TheDutchRuler.
- Plugins/runtime-deps: cache unchanged bundled runtime mirror dist-file materialization decisions and close file-lock handles on owner-write failures, reducing repeated startup chunk scans and avoiding FileHandle-GC recovery stalls. Refs #73532. Thanks @oadiazp and @bstanbury.
- Plugins/runtime-deps: retry and defer transient cleanup failures for owned runtime staging directories so CLI startup no longer aborts after a successful bundled dependency swap. Refs #73903. Thanks @bobfreeman1989.
- CLI/TUI: keep `chat.history` off model-catalog discovery so initial Gateway-backed TUI history loads cannot block behind slow provider/plugin model scans on low-core hosts. Refs #73524. Thanks @harshcatsystems-collab.
- Channels/WhatsApp: flag recently reconnected linked accounts in channel status even when the socket is currently healthy, so flapping WhatsApp Web sessions no longer look clean after a brief reconnect. Refs #73602. Thanks @Vksh07.
- Gateway: expose `gateway.handshakeTimeoutMs` in config, schema, and docs while preserving `OPENCLAW_HANDSHAKE_TIMEOUT_MS` precedence, so loaded or low-powered hosts can tune local WebSocket pre-auth handshakes without patching dist files. Supersedes #51282; refs #73592 and #73652. Thanks @henry-the-frog.
- Agents/model selection: resolve slash-form aliases before provider/model parsing and keep alias-resolved primary models subject to transient provider cooldowns, so cron and persisted sessions do not retry cooled-down raw aliases. Fixes #73573 and #73657. Thanks @akai-shuuichi and @hashslingers.
- Agents/Claude CLI: reuse already-cached macOS Keychain credentials for no-prompt Claude credential reads, so doctor/runtime checks do not miss fresh interactive Claude auth. Fixes #73682. Thanks @RyanSandoval.
- Agents/Claude CLI doctor: scope workspace and project-dir checks to agents that actually use the Claude CLI runtime, so non-default Claude agents no longer make the default agent look Claude-backed. Fixes #73903. Thanks @bobfreeman1989.
- Agents/runtime status: expose effective agent runtime metadata in `agents.list`, Control UI agent panels, and `/agents`, and avoid rendering stale or cumulative CLI token totals as live context usage. Fixes #73660, #73578, and #45268. Thanks @spartman, @DashLabsDev, and @xyooz.
- Agents/transcripts: strip empty assistant text blocks while preserving valid text, images, and signatures, so Anthropic-style providers no longer reject sanitized transcript turns. Fixes #73640. Thanks @jowhee327.
- Providers/Bedrock: omit deprecated `temperature` for Claude Opus 4.7 Bedrock model ids, named and application inference profiles, including dotted `opus-4.7` refs, and classify the nested validation response for failover. Fixes #73663. Thanks @bstanbury.

View File

@@ -25,18 +25,31 @@ function readOptionalUtf8(filePath) {
return fs.readFileSync(filePath, "utf8");
}
function removePathIfExists(targetPath) {
for (let attempt = 0; ; attempt += 1) {
function removePathIfExists(targetPath, options = {}) {
const retryDelays = options.retryTransient ? TEMP_REMOVE_RETRY_DELAYS_MS : [];
for (let attempt = 0; attempt <= retryDelays.length; attempt += 1) {
try {
fs.rmSync(targetPath, { recursive: true, force: true });
return;
return true;
} catch (error) {
if (!isTransientTempRemoveError(error) || attempt >= TEMP_REMOVE_RETRY_DELAYS_MS.length) {
if (!isTransientTempRemoveError(error)) {
throw error;
}
sleepSync(TEMP_REMOVE_RETRY_DELAYS_MS[attempt]);
const delay = retryDelays[attempt];
if (delay === undefined) {
if (options.ignoreTransient) {
return false;
}
throw error;
}
sleepSync(delay);
}
}
return true;
}
function removeOwnedTempPathBestEffort(targetPath) {
return removePathIfExists(targetPath, { retryTransient: true, ignoreTransient: true });
}
function isTransientTempRemoveError(error) {
@@ -102,7 +115,7 @@ function replaceDirAtomically(targetPath, sourcePath) {
targetParentDir,
`.openclaw-runtime-deps-backup-${sanitizeTempPrefixSegment(path.basename(targetPath))}-`,
);
removePathIfExists(backupPath);
removePathIfExists(backupPath, { retryTransient: true });
let movedExistingTarget = false;
try {
@@ -112,7 +125,7 @@ function replaceDirAtomically(targetPath, sourcePath) {
movedExistingTarget = true;
}
fs.renameSync(sourcePath, targetPath);
removePathIfExists(backupPath);
removeOwnedTempPathBestEffort(backupPath);
} catch (error) {
if (movedExistingTarget && !fs.existsSync(targetPath) && fs.existsSync(backupPath)) {
fs.renameSync(backupPath, targetPath);
@@ -138,7 +151,7 @@ function writeJsonAtomically(targetPath, value) {
});
fs.renameSync(tempPath, targetPath);
} finally {
removePathIfExists(tempDir);
removeOwnedTempPathBestEffort(tempDir);
}
}
@@ -1024,21 +1037,7 @@ function removeStaleRuntimeDepsTempDirs(pluginDir) {
if (!shouldRemoveRuntimeDepsTempDir(targetPath)) {
continue;
}
for (let attempt = 0; attempt <= TEMP_REMOVE_RETRY_DELAYS_MS.length; attempt += 1) {
try {
removePathIfExists(targetPath);
break;
} catch (error) {
if (!isTransientTempRemoveError(error)) {
throw error;
}
const delay = TEMP_REMOVE_RETRY_DELAYS_MS[attempt];
if (delay === undefined) {
break;
}
sleepSync(delay);
}
}
removeOwnedTempPathBestEffort(targetPath);
}
}
}
@@ -1134,7 +1133,7 @@ function stageInstalledRootRuntimeDeps(params) {
});
return true;
} finally {
removePathIfExists(path.dirname(stagedNodeModulesDir));
removeOwnedTempPathBestEffort(path.dirname(stagedNodeModulesDir));
}
}
@@ -1226,7 +1225,7 @@ function installPluginRuntimeDeps(params) {
generatedAt: new Date().toISOString(),
});
} finally {
removePathIfExists(tempInstallDir);
removeOwnedTempPathBestEffort(tempInstallDir);
}
}

View File

@@ -112,6 +112,71 @@ describe("noteClaudeCliHealth", () => {
});
});
it("reports the Claude CLI workspace for a non-default runtime agent", async () => {
await withTempHome(({ homeDir, workspaceDir }) => {
const root = path.dirname(workspaceDir);
const defaultWorkspace = path.join(root, "workspace-coder");
const claudeWorkspace = path.join(root, "workspace-xiaoao");
fs.mkdirSync(defaultWorkspace, { recursive: true });
fs.mkdirSync(claudeWorkspace, { recursive: true });
const projectDir = resolveClaudeCliProjectDirForWorkspace({
workspaceDir: claudeWorkspace,
homeDir,
});
fs.mkdirSync(projectDir, { recursive: true });
const noteFn = vi.fn();
noteClaudeCliHealth(
{
agents: {
defaults: {
agentRuntime: { id: "codex" },
model: { primary: "openai/gpt-5.5" },
},
list: [
{
id: "coder",
default: true,
workspace: defaultWorkspace,
agentRuntime: { id: "codex" },
},
{
id: "xiaoao",
workspace: claudeWorkspace,
agentRuntime: { id: "claude-cli", fallback: "none" },
model: "anthropic/claude-opus-4-7",
},
],
},
},
{
homeDir,
noteFn,
store: createStore({
[CLAUDE_CLI_PROFILE_ID]: {
type: "oauth",
provider: "claude-cli",
access: "token-a",
refresh: "token-r",
expires: Date.now() + 60_000,
},
}),
readClaudeCliCredentials: () => ({
type: "oauth",
expires: Date.now() + 60_000,
}),
resolveCommandPath: () => "/opt/homebrew/bin/claude",
},
);
expect(noteFn).toHaveBeenCalledTimes(1);
const body = String(noteFn.mock.calls[0]?.[0]);
expect(body).toContain(`Agent xiaoao workspace: ${claudeWorkspace} (writable).`);
expect(body).toContain(`Agent xiaoao Claude project dir: ${projectDir} (present).`);
expect(body).not.toContain(defaultWorkspace);
});
});
it("explains the exact bad wiring when the claude-cli auth profile is missing", async () => {
await withTempHome(({ homeDir, workspaceDir }) => {
const noteFn = vi.fn();

View File

@@ -1,13 +1,15 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { resolveAgentRuntimeMetadata } from "../agents/agent-runtime-metadata.js";
import {
listAgentIds,
resolveAgentWorkspaceDir,
resolveDefaultAgentId,
} from "../agents/agent-scope.js";
import { CLAUDE_CLI_PROFILE_ID } from "../agents/auth-profiles/constants.js";
import { resolveAuthStorePathForDisplay } from "../agents/auth-profiles/paths.js";
import {
ensureAuthProfileStore,
hasAnyAuthProfileStoreSource,
} from "../agents/auth-profiles/store.js";
import { ensureAuthProfileStore } from "../agents/auth-profiles/store.js";
import type {
AuthProfileStore,
OAuthCredential,
@@ -47,30 +49,6 @@ function usesClaudeCliModelSelection(cfg: OpenClawConfig): boolean {
);
}
function hasClaudeCliConfigSignals(cfg: OpenClawConfig): boolean {
if (usesClaudeCliModelSelection(cfg)) {
return true;
}
const backendConfig = cfg.agents?.defaults?.cliBackends ?? {};
if (
Object.keys(backendConfig).some(
(key) => normalizeOptionalLowercaseString(key) === CLAUDE_CLI_PROVIDER,
)
) {
return true;
}
return Object.values(cfg.auth?.profiles ?? {}).some(
(profile) => profile?.provider === CLAUDE_CLI_PROVIDER,
);
}
function hasClaudeCliStoreSignals(store: AuthProfileStore): boolean {
if (store.profiles[CLAUDE_CLI_PROFILE_ID]) {
return true;
}
return Object.values(store.profiles).some((profile) => profile?.provider === CLAUDE_CLI_PROVIDER);
}
function resolveClaudeCliCommand(cfg: OpenClawConfig): string {
const configured = cfg.agents?.defaults?.cliBackends ?? {};
for (const [key, entry] of Object.entries(configured)) {
@@ -152,38 +130,106 @@ function formatCredentialLabel(credential: ClaudeCliReadableCredential): string
return "unknown";
}
function formatWorkspaceHealthLine(workspaceDir: string, health: ClaudeCliDirHealth): string {
function formatWorkspaceHealthLine(
workspaceDir: string,
health: ClaudeCliDirHealth,
agentId?: string,
): string {
const label = agentId ? `Agent ${agentId} workspace` : "Workspace";
const display = shortenHomePath(workspaceDir);
if (health === "present") {
return `- Workspace: ${display} (writable).`;
return `- ${label}: ${display} (writable).`;
}
if (health === "missing") {
return `- Workspace: ${display} (missing; OpenClaw will create it on first run).`;
return `- ${label}: ${display} (missing; OpenClaw will create it on first run).`;
}
if (health === "not_directory") {
return `- Workspace: ${display} exists but is not a directory.`;
return `- ${label}: ${display} exists but is not a directory.`;
}
if (health === "unreadable") {
return `- Workspace: ${display} is not readable by this user.`;
return `- ${label}: ${display} is not readable by this user.`;
}
return `- Workspace: ${display} is not writable by this user.`;
return `- ${label}: ${display} is not writable by this user.`;
}
function formatProjectDirHealthLine(projectDir: string, health: ClaudeCliDirHealth): string {
function formatProjectDirHealthLine(
projectDir: string,
health: ClaudeCliDirHealth,
agentId?: string,
): string {
const label = agentId ? `Agent ${agentId} Claude project dir` : "Claude project dir";
const display = shortenHomePath(projectDir);
if (health === "present") {
return `- Claude project dir: ${display} (present).`;
return `- ${label}: ${display} (present).`;
}
if (health === "missing") {
return `- Claude project dir: ${display} (not created yet; it appears after the first Claude CLI turn in this workspace).`;
return `- ${label}: ${display} (not created yet; it appears after the first Claude CLI turn in this workspace).`;
}
if (health === "not_directory") {
return `- Claude project dir: ${display} exists but is not a directory.`;
return `- ${label}: ${display} exists but is not a directory.`;
}
if (health === "unreadable") {
return `- Claude project dir: ${display} is not readable by this user.`;
return `- ${label}: ${display} is not readable by this user.`;
}
return `- Claude project dir: ${display} is not writable by this user.`;
return `- ${label}: ${display} is not writable by this user.`;
}
function resolveClaudeCliAgentIds(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): string[] {
const agentIds = listAgentIds(cfg);
const runtimeAgentIds = agentIds.filter(
(agentId) => resolveAgentRuntimeMetadata(cfg, agentId, env).id === CLAUDE_CLI_PROVIDER,
);
if (runtimeAgentIds.length > 0) {
return runtimeAgentIds;
}
if (usesClaudeCliModelSelection(cfg)) {
return [resolveDefaultAgentId(cfg)];
}
return [];
}
type ClaudeCliWorkspaceTarget = {
agentId: string;
workspaceDir: string;
projectDir: string;
workspaceHealth: ClaudeCliDirHealth;
projectDirHealth: ClaudeCliDirHealth;
};
function resolveClaudeCliWorkspaceTargets(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
homeDir?: string;
workspaceDir?: string;
}): ClaudeCliWorkspaceTarget[] {
const agentIds = resolveClaudeCliAgentIds(params.cfg, params.env);
const defaultAgentId = resolveDefaultAgentId(params.cfg);
const seen = new Set<string>();
return agentIds
.filter((agentId) => {
if (seen.has(agentId)) {
return false;
}
seen.add(agentId);
return true;
})
.map((agentId) => {
const workspaceDir =
params.workspaceDir && agentIds.length === 1 && agentId === defaultAgentId
? params.workspaceDir
: resolveAgentWorkspaceDir(params.cfg, agentId, params.env);
const projectDir = resolveClaudeCliProjectDirForWorkspace({
workspaceDir,
homeDir: params.homeDir,
});
return {
agentId,
workspaceDir,
projectDir,
workspaceHealth: probeDirectoryHealth(workspaceDir),
projectDirHealth: probeDirectoryHealth(projectDir),
};
});
}
export function noteClaudeCliHealth(
@@ -198,38 +244,34 @@ export function noteClaudeCliHealth(
workspaceDir?: string;
},
) {
const hasConfigSignals = hasClaudeCliConfigSignals(cfg);
const store =
deps?.store ??
(hasConfigSignals || hasAnyAuthProfileStoreSource()
? ensureAuthProfileStore(undefined, { allowKeychainPrompt: false })
: ({ version: 1, profiles: {} } as AuthProfileStore));
const env = deps?.env ?? process.env;
const workspaceTargets = resolveClaudeCliWorkspaceTargets({
cfg,
env,
homeDir: deps?.homeDir,
workspaceDir: deps?.workspaceDir,
});
if (workspaceTargets.length === 0) {
return;
}
const store = deps?.store ?? ensureAuthProfileStore(undefined, { allowKeychainPrompt: false });
const readClaudeCliCredentials =
deps?.readClaudeCliCredentials ??
(() => readClaudeCliCredentialsCached({ allowKeychainPrompt: false }));
const credential = readClaudeCliCredentials();
if (!hasConfigSignals && !hasClaudeCliStoreSignals(store) && !credential) {
return;
}
const env = deps?.env ?? process.env;
const command = resolveClaudeCliCommand(cfg);
const resolveCommandPath =
deps?.resolveCommandPath ??
((rawCommand: string, nextEnv?: NodeJS.ProcessEnv) =>
resolveExecutablePath(rawCommand, { env: nextEnv }));
const commandPath = resolveCommandPath(command, env);
const workspaceDir =
deps?.workspaceDir ?? resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg));
const projectDir = resolveClaudeCliProjectDirForWorkspace({
workspaceDir,
homeDir: deps?.homeDir,
});
const workspaceHealth = probeDirectoryHealth(workspaceDir);
const projectDirHealth = probeDirectoryHealth(projectDir);
const authStorePath = resolveAuthStorePathForDisplay();
const storedProfile = store.profiles[CLAUDE_CLI_PROFILE_ID];
const defaultAgentId = resolveDefaultAgentId(cfg);
const showAgentLabels =
workspaceTargets.length > 1 ||
workspaceTargets.some((target) => target.agentId !== defaultAgentId);
const lines: string[] = [];
const fixHints: string[] = [];
@@ -276,19 +318,34 @@ export function noteClaudeCliHealth(
);
}
lines.push(formatWorkspaceHealthLine(workspaceDir, workspaceHealth));
if (
workspaceHealth === "readonly" ||
workspaceHealth === "unreadable" ||
workspaceHealth === "not_directory"
) {
fixHints.push("- Fix: make the workspace a readable, writable directory for the gateway user.");
for (const target of workspaceTargets) {
const agentLabel = showAgentLabels ? target.agentId : undefined;
lines.push(formatWorkspaceHealthLine(target.workspaceDir, target.workspaceHealth, agentLabel));
if (
target.workspaceHealth === "readonly" ||
target.workspaceHealth === "unreadable" ||
target.workspaceHealth === "not_directory"
) {
fixHints.push(
`- Fix: make ${
agentLabel ? `agent ${agentLabel}'s workspace` : "the workspace"
} a readable, writable directory for the gateway user.`,
);
}
lines.push(formatProjectDirHealthLine(target.projectDir, target.projectDirHealth, agentLabel));
if (target.projectDirHealth === "unreadable" || target.projectDirHealth === "not_directory") {
fixHints.push(
`- Fix: make ${
agentLabel ? `agent ${agentLabel}'s Claude project dir` : "the Claude project dir"
} readable, or remove the broken path and let Claude recreate it.`,
);
}
}
lines.push(formatProjectDirHealthLine(projectDir, projectDirHealth));
if (projectDirHealth === "unreadable" || projectDirHealth === "not_directory") {
fixHints.push(
"- Fix: make the Claude project dir readable, or remove the broken path and let Claude recreate it.",
if (workspaceTargets.length > 1) {
lines.push(
`- Agents using Claude CLI: ${workspaceTargets.map((target) => target.agentId).join(", ")}.`,
);
}

View File

@@ -451,6 +451,98 @@ describe("stageBundledPluginRuntimeDeps", () => {
expect(fs.readFileSync(path.join(targetPath, "marker.txt"), "utf8")).toBe("replacement\n");
});
it("keeps a successful replacement when backup cleanup hits transient ENOTEMPTY", () => {
const parentDir = createTempDir("openclaw-runtime-deps-replace-cleanup-");
const targetPath = path.join(parentDir, "node_modules");
const sourcePath = path.join(parentDir, "source-node_modules");
fs.mkdirSync(targetPath, { recursive: true });
fs.writeFileSync(path.join(targetPath, "marker.txt"), "original\n", "utf8");
fs.mkdirSync(sourcePath, { recursive: true });
fs.writeFileSync(path.join(sourcePath, "marker.txt"), "replacement\n", "utf8");
const realRenameSync = fs.renameSync.bind(fs);
const realRmSync = fs.rmSync.bind(fs);
let backupPath: string | null = null;
vi.spyOn(fs, "renameSync").mockImplementation((oldPath, newPath) => {
const oldPathString = String(oldPath);
const newPathString = String(newPath);
if (
oldPathString === targetPath &&
path.basename(newPathString).startsWith(".openclaw-runtime-deps-backup-")
) {
backupPath = newPathString;
}
return realRenameSync(oldPath, newPath);
});
vi.spyOn(fs, "rmSync").mockImplementation((target, options) => {
const targetString = String(target);
if (
backupPath &&
targetString === backupPath &&
fs.existsSync(path.join(backupPath, "marker.txt"))
) {
const error = new Error("Directory not empty") as NodeJS.ErrnoException;
error.code = "ENOTEMPTY";
throw error;
}
return realRmSync(target, options);
});
expect(() =>
stageBundledPluginRuntimeDepsTesting.replaceDirAtomically(targetPath, sourcePath),
).not.toThrow();
expect(fs.readFileSync(path.join(targetPath, "marker.txt"), "utf8")).toBe("replacement\n");
expect(backupPath).not.toBeNull();
expect(fs.readFileSync(path.join(backupPath ?? "", "marker.txt"), "utf8")).toBe("original\n");
expect(fs.existsSync(path.join(backupPath ?? "", "owner.json"))).toBe(true);
});
it("keeps successful root staging when owned stage temp cleanup races", () => {
const { pluginDir, repoRoot } = createBundledPluginFixture({
packageJson: {
name: "@openclaw/fixture-plugin",
version: "1.0.0",
dependencies: { direct: "1.0.0" },
openclaw: { bundle: { stageRuntimeDependencies: true } },
},
});
const directDir = path.join(repoRoot, "node_modules", "direct");
fs.mkdirSync(directDir, { recursive: true });
fs.writeFileSync(
path.join(directDir, "package.json"),
'{ "name": "direct", "version": "1.0.0" }\n',
"utf8",
);
fs.writeFileSync(path.join(directDir, "index.js"), "module.exports = 'direct';\n", "utf8");
const realRmSync = fs.rmSync.bind(fs);
let cleanupAttempts = 0;
vi.spyOn(fs, "rmSync").mockImplementation((target, options) => {
const targetString = String(target);
if (
targetString.startsWith(path.join(pluginDir, ".openclaw-runtime-deps-stage-")) &&
cleanupAttempts === 0
) {
cleanupAttempts += 1;
const error = new Error("Directory not empty") as NodeJS.ErrnoException;
error.code = "ENOTEMPTY";
throw error;
}
if (targetString.startsWith(path.join(pluginDir, ".openclaw-runtime-deps-stage-"))) {
cleanupAttempts += 1;
}
return realRmSync(target, options);
});
expect(() => stageBundledPluginRuntimeDeps({ cwd: repoRoot })).not.toThrow();
expect(cleanupAttempts).toBe(2);
expect(
fs.readFileSync(path.join(pluginDir, "node_modules", "direct", "index.js"), "utf8"),
).toBe("module.exports = 'direct';\n");
});
it("restages when installed root runtime dependency contents change", () => {
const { pluginDir, repoRoot } = createBundledPluginFixture({
packageJson: {