diff --git a/CHANGELOG.md b/CHANGELOG.md index 73d1bebc7e7..4b0a1a64c9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/scripts/stage-bundled-plugin-runtime-deps.mjs b/scripts/stage-bundled-plugin-runtime-deps.mjs index 42d22445fcc..b93b74cb4c7 100644 --- a/scripts/stage-bundled-plugin-runtime-deps.mjs +++ b/scripts/stage-bundled-plugin-runtime-deps.mjs @@ -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); } } diff --git a/src/commands/doctor-claude-cli.test.ts b/src/commands/doctor-claude-cli.test.ts index cde4d31ef43..696eea4c59a 100644 --- a/src/commands/doctor-claude-cli.test.ts +++ b/src/commands/doctor-claude-cli.test.ts @@ -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(); diff --git a/src/commands/doctor-claude-cli.ts b/src/commands/doctor-claude-cli.ts index d0936520531..1434fa07e25 100644 --- a/src/commands/doctor-claude-cli.ts +++ b/src/commands/doctor-claude-cli.ts @@ -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(); + 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(", ")}.`, ); } diff --git a/test/scripts/stage-bundled-plugin-runtime-deps.test.ts b/test/scripts/stage-bundled-plugin-runtime-deps.test.ts index 88f0d74ff1a..421ff267338 100644 --- a/test/scripts/stage-bundled-plugin-runtime-deps.test.ts +++ b/test/scripts/stage-bundled-plugin-runtime-deps.test.ts @@ -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: {