mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 10:30:44 +00:00
fix: scope claude doctor runtime checks
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(", ")}.`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user