mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 22:17:00 +00:00
fix(infra): unify env-truthiness, missing-path, realpath, and abort-sleep semantics (#120359)
* fix(infra): unify environment truthiness * fix(infra): unify missing path classification * refactor(infra): unify realpath fallbacks * fix(infra): unify abortable sleep errors * docs(infra): clarify path fallback semantics * fix(infra): route realpaths through policy wrapper * fix(infra): ratchet plugin SDK wildcard budget * fix(agents): preserve zero-delay abort precedence * fix(infra): preserve fallback and media recovery contracts * fix(plugins): share quarantine path resolution
This commit is contained in:
committed by
GitHub
parent
a07d0e249d
commit
eecbfcc960
@@ -437,7 +437,6 @@ src/agents/openclaw-tools.media-factory-plan.test.ts
|
||||
src/agents/openclaw-tools.session-status.test.ts
|
||||
src/agents/openclaw-tools.sessions.test.ts
|
||||
src/agents/provider-attribution.test.ts
|
||||
src/agents/provider-local-service.ts
|
||||
src/agents/provider-request-config.ts
|
||||
src/agents/provider-transport-fetch.test.ts
|
||||
src/agents/provider-transport-fetch.ts
|
||||
|
||||
@@ -74,7 +74,7 @@ d117ebba8cc490501725778676a9d75855872b6e5fe2b2f64b1270d4808a2277 module/health
|
||||
f6e3c44e7d1090a97aca554a3c247219b8de78b3cb4399cac5efde8a0a6c1156 module/inbound-envelope
|
||||
1d5ca69fcf2a8476a2309dcd830fc3bbae04b7029fa30eea1081b6590a789f10 module/inbound-event-delivery
|
||||
36721c58f479fe9ca32737f67850f4607bf8753ef6b193ff129e7a9e5fdbc6bd module/inbound-reply-dispatch
|
||||
c87e2ccdecbc69f2e2648441dc7a342828b06fe89d30c1523bcd8738b5158754 module/infra-runtime
|
||||
fe7172bcab3d1adf00e7bcfce6beaea9f3b231df251d5e5ad8dc2d20abd90eca module/infra-runtime
|
||||
2e717cccb3db127aed0287d4ea14c41a8e64e46d60c728638153e31e2fb0d296 module/ingress-effect-once
|
||||
9389d91a090259f06e5ec2aae14c9857e45988d4edc4f91170cb8bc573b966a2 module/interactive-runtime
|
||||
9dd66baf2def46386ad4706380e57f9068fa9bb3878be2d9ba961ec2f46d3d87 module/json-store
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -51,9 +51,14 @@ describe("RetrySupervisor", () => {
|
||||
});
|
||||
const retry = supervisor.next();
|
||||
const wait = sleepWithAbort(retry?.delayMs ?? 0, retry?.signal);
|
||||
supervisor.cancel(new Error("stop"));
|
||||
const reason = new Error("stop");
|
||||
supervisor.cancel(reason);
|
||||
|
||||
await expect(wait).rejects.toMatchObject({ message: "aborted" });
|
||||
await expect(wait).rejects.toMatchObject({
|
||||
name: "AbortError",
|
||||
message: "aborted",
|
||||
cause: reason,
|
||||
});
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
@@ -69,7 +74,7 @@ describe("RetrySupervisor", () => {
|
||||
|
||||
expect(timer?.hasRef()).toBe(false);
|
||||
controller.abort();
|
||||
await expect(sleeper).rejects.toMatchObject({ message: "aborted" });
|
||||
await expect(sleeper).rejects.toMatchObject({ name: "AbortError", message: "aborted" });
|
||||
} finally {
|
||||
controller.abort();
|
||||
setTimeoutSpy.mockRestore();
|
||||
|
||||
@@ -41,7 +41,12 @@ export async function sleepWithAbort(
|
||||
}
|
||||
timer = null;
|
||||
cleanup();
|
||||
reject(new Error("aborted", { cause: abortSignal?.reason ?? new Error("aborted") }));
|
||||
// This leaf package cannot import the host abort helper; preserve its contract here.
|
||||
const error = new Error("aborted", {
|
||||
cause: abortSignal?.reason ?? new Error("aborted"),
|
||||
});
|
||||
error.name = "AbortError";
|
||||
reject(error);
|
||||
};
|
||||
abortSignal?.addEventListener("abort", onAbort, { once: true });
|
||||
if (abortSignal?.aborted) {
|
||||
|
||||
@@ -292,7 +292,8 @@ export function readPluginSdkSurfaceBudgets(env = process.env) {
|
||||
publicWildcardReexports: readPluginSdkSurfaceBudgetEnv(
|
||||
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_WILDCARD_REEXPORTS",
|
||||
// -1: text-runtime now names its global-singleton exports explicitly.
|
||||
81,
|
||||
// -1: infra-runtime now names its error exports explicitly.
|
||||
80,
|
||||
env,
|
||||
),
|
||||
};
|
||||
|
||||
@@ -19,6 +19,7 @@ import { resolveStorePath } from "../config/sessions/paths.js";
|
||||
import { loadSessionEntry } from "../config/sessions/session-accessor.js";
|
||||
import type { SessionEntry } from "../config/sessions/types.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { isMissingPathError } from "../infra/errors.js";
|
||||
import {
|
||||
getSessionBindingService,
|
||||
type SessionBindingRecord,
|
||||
@@ -39,11 +40,6 @@ export function resolveAcpSessionMode(mode: "run" | "session"): AcpRuntimeSessio
|
||||
return mode === "session" ? "persistent" : "oneshot";
|
||||
}
|
||||
|
||||
function isMissingPathError(error: unknown): boolean {
|
||||
const code = error instanceof Error ? (error as NodeJS.ErrnoException).code : undefined;
|
||||
return code === "ENOENT" || code === "ENOTDIR";
|
||||
}
|
||||
|
||||
export async function resolveRuntimeCwdForAcpSpawn(params: {
|
||||
resolvedCwd?: string;
|
||||
explicitCwd?: string;
|
||||
|
||||
@@ -8,7 +8,7 @@ import { URL } from "node:url";
|
||||
import { detectMime } from "@openclaw/media-core/mime";
|
||||
import { formatByteSize } from "@openclaw/normalization-core";
|
||||
import { isWindowsDrivePath } from "../infra/archive-path.js";
|
||||
import { toErrorObject } from "../infra/errors.js";
|
||||
import { isMissingPathError, toErrorObject } from "../infra/errors.js";
|
||||
import {
|
||||
canonicalPathFromExistingAncestor,
|
||||
root as fsRoot,
|
||||
@@ -268,9 +268,10 @@ function normalizeDailyMemoryReadPath(value: unknown): string | undefined {
|
||||
}
|
||||
|
||||
function isNotFoundError(error: unknown): boolean {
|
||||
if (typeof (error as NodeJS.ErrnoException | undefined)?.code === "string") {
|
||||
return (error as NodeJS.ErrnoException).code === "ENOENT";
|
||||
if (isMissingPathError(error)) {
|
||||
return true;
|
||||
}
|
||||
// Injected tool implementations may expose only their legacy human-readable error.
|
||||
if (!(error instanceof Error)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -227,5 +227,16 @@ function assertBoundaryRead(
|
||||
return;
|
||||
}
|
||||
const reason = opened.reason === "validation" ? "unsafe path" : "path not found";
|
||||
throw new Error(`Failed boundary read for ${targetPath} (${reason})`);
|
||||
const error = new Error(`Failed boundary read for ${targetPath} (${reason})`) as Error & {
|
||||
code?: string;
|
||||
};
|
||||
const sourceCode =
|
||||
opened.error && typeof opened.error === "object" && "code" in opened.error
|
||||
? opened.error.code
|
||||
: undefined;
|
||||
if (sourceCode === "ENOENT" || sourceCode === "ENOTDIR") {
|
||||
// Preserve the producer's classification so provenance observers do not parse messages.
|
||||
error.code = sourceCode;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coe
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { openRootFileSync } from "../infra/boundary-file-read.js";
|
||||
import { readFileDescriptorBoundedSync } from "../infra/boundary-file-read.js";
|
||||
import { resolveRealpathOrAbsolute } from "../infra/boundary-path.js";
|
||||
import { isRenderableAvatarImageDataUrl } from "../shared/avatar-limits.js";
|
||||
import {
|
||||
AVATAR_MAX_BYTES,
|
||||
@@ -43,14 +44,6 @@ type LocalAgentAvatarPath = {
|
||||
workspaceRoot: string;
|
||||
};
|
||||
|
||||
function resolveExistingPath(value: string): string {
|
||||
try {
|
||||
return fs.realpathSync(value);
|
||||
} catch {
|
||||
return path.resolve(value);
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve one local avatar source while retaining its canonical workspace root. */
|
||||
export function resolveLocalAgentAvatarPath(params: {
|
||||
raw: string;
|
||||
@@ -58,12 +51,12 @@ export function resolveLocalAgentAvatarPath(params: {
|
||||
}):
|
||||
| { ok: true; value: LocalAgentAvatarPath }
|
||||
| { ok: false; reason: LocalAgentAvatarFailureReason } {
|
||||
const workspaceRoot = resolveExistingPath(params.workspaceDir);
|
||||
const workspaceRoot = resolveRealpathOrAbsolute(params.workspaceDir);
|
||||
const resolved =
|
||||
params.raw.startsWith("~") || path.isAbsolute(params.raw)
|
||||
? resolveUserPath(params.raw)
|
||||
: path.resolve(workspaceRoot, params.raw);
|
||||
const filePath = resolveExistingPath(resolved);
|
||||
const filePath = resolveRealpathOrAbsolute(resolved);
|
||||
if (!isPathWithinRoot(workspaceRoot, filePath)) {
|
||||
return { ok: false, reason: "outside_workspace" };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { realpathSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { isMissingPathError } from "../infra/errors.js";
|
||||
import { logWarn } from "../logger.js";
|
||||
import type { MemoryFlushPlan } from "../plugins/memory-state.js";
|
||||
|
||||
@@ -20,11 +21,6 @@ type ProvenanceWriteOperations = {
|
||||
remove?: (absolutePath: string) => Promise<void>;
|
||||
};
|
||||
|
||||
function isMissingFileError(error: unknown): boolean {
|
||||
const code = (error as { code?: unknown } | undefined)?.code;
|
||||
return code === "ENOENT" || code === "not-found" || String(error).includes("(path not found)");
|
||||
}
|
||||
|
||||
export function withMemoryWriteProvenance<T extends ProvenanceWriteOperations>(
|
||||
operations: T,
|
||||
observer: MemoryWriteProvenanceObserver | undefined,
|
||||
@@ -44,7 +40,7 @@ export function withMemoryWriteProvenance<T extends ProvenanceWriteOperations>(
|
||||
.readFile(absolutePath)
|
||||
.then((value) => (Buffer.isBuffer(value) ? value.toString("utf8") : value))
|
||||
.catch((error: unknown) => {
|
||||
if (!isMissingFileError(error)) {
|
||||
if (!isMissingPathError(error)) {
|
||||
throw error;
|
||||
}
|
||||
return "";
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
clampPositiveTimerTimeoutMs,
|
||||
resolvePositiveTimerTimeoutMs,
|
||||
} from "@openclaw/normalization-core/number-coercion";
|
||||
import { sleepWithAbort } from "@openclaw/retry";
|
||||
import type { ModelProviderLocalServiceConfig } from "../config/types.models.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { toErrorObject } from "../infra/errors.js";
|
||||
@@ -505,7 +506,7 @@ async function startAndWaitForLocalService(params: {
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error(`${provider} local service did not become ready at ${healthUrl}`);
|
||||
}
|
||||
await sleep(PROBE_INTERVAL_MS, signal);
|
||||
await sleepWithAbort(PROBE_INTERVAL_MS, signal, { ref: false });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -703,25 +704,6 @@ function waitForAbort<T>(promise: Promise<T>, signal?: AbortSignal | null): Prom
|
||||
});
|
||||
}
|
||||
|
||||
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
throwIfAborted(signal);
|
||||
return new Promise((resolve, reject) => {
|
||||
const cleanup = () => signal?.removeEventListener("abort", onAbort);
|
||||
const onDone = () => {
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
const onAbort = () => {
|
||||
clearTimeout(timeout);
|
||||
cleanup();
|
||||
reject(toAbortError(signal));
|
||||
};
|
||||
const timeout: NodeJS.Timeout = setTimeout(onDone, ms);
|
||||
timeout.unref?.();
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
function waitForSpawnResult(
|
||||
child: ChildProcess,
|
||||
signal?: AbortSignal | null,
|
||||
@@ -790,4 +772,3 @@ export function hasLocalServiceProcessExited(
|
||||
): boolean {
|
||||
return child.exitCode !== null || child.signalCode !== null;
|
||||
}
|
||||
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
|
||||
|
||||
18
src/agents/sessions/telemetry.test.ts
Normal file
18
src/agents/sessions/telemetry.test.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { SettingsManager } from "./settings-manager.js";
|
||||
import { isInstallTelemetryEnabled } from "./telemetry.js";
|
||||
|
||||
describe("isInstallTelemetryEnabled", () => {
|
||||
const settings = (enabled: boolean) =>
|
||||
({ getEnableInstallTelemetry: vi.fn(() => enabled) }) as unknown as SettingsManager;
|
||||
|
||||
it("uses the canonical operator env truth table", () => {
|
||||
expect(isInstallTelemetryEnabled(settings(false), " ON ")).toBe(true);
|
||||
expect(isInstallTelemetryEnabled(settings(true), "off")).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to persisted settings only when the env override is absent", () => {
|
||||
expect(isInstallTelemetryEnabled(settings(true), undefined)).toBe(true);
|
||||
expect(isInstallTelemetryEnabled(settings(true), "")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isTruthyEnvValue } from "../../infra/env.js";
|
||||
/**
|
||||
* Install telemetry switch.
|
||||
*
|
||||
@@ -5,19 +6,12 @@
|
||||
*/
|
||||
import type { SettingsManager } from "./settings-manager.js";
|
||||
|
||||
function isTruthyEnvFlag(value: string | undefined): boolean {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
return value === "1" || value.toLowerCase() === "true" || value.toLowerCase() === "yes";
|
||||
}
|
||||
|
||||
/** Resolves whether install telemetry is enabled from env override or settings. */
|
||||
export function isInstallTelemetryEnabled(
|
||||
settingsManager: SettingsManager,
|
||||
telemetryEnv: string | undefined = process.env.OPENCLAW_TELEMETRY,
|
||||
): boolean {
|
||||
return telemetryEnv !== undefined
|
||||
? isTruthyEnvFlag(telemetryEnv)
|
||||
? isTruthyEnvValue(telemetryEnv)
|
||||
: settingsManager.getEnableInstallTelemetry();
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { dirname } from "node:path";
|
||||
import { Container, Text } from "@earendil-works/pi-tui";
|
||||
import { structuredPatch } from "diff";
|
||||
import { Type } from "typebox";
|
||||
import { isMissingPathError } from "../../../infra/errors.js";
|
||||
import { keyHint } from "../../modes/interactive/components/keybinding-hints.js";
|
||||
import { getLanguageFromPath, highlightCode } from "../../modes/interactive/theme/theme.js";
|
||||
import type { AgentTool } from "../../runtime/index.js";
|
||||
@@ -94,12 +95,7 @@ const defaultWriteOperations: WriteOperations = {
|
||||
mtimeMs: stat.mtimeMs,
|
||||
} as const;
|
||||
} catch (error) {
|
||||
if (
|
||||
error &&
|
||||
typeof error === "object" &&
|
||||
"code" in error &&
|
||||
(error as { code?: unknown }).code === "ENOENT"
|
||||
) {
|
||||
if (isMissingPathError(error)) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
@@ -315,12 +311,10 @@ function formatWriteResult(
|
||||
}
|
||||
|
||||
function isMissingFileError(error: unknown): boolean {
|
||||
if (!error || typeof error !== "object") {
|
||||
return false;
|
||||
}
|
||||
if ("code" in error && (error as { code?: unknown }).code === "ENOENT") {
|
||||
if (isMissingPathError(error)) {
|
||||
return true;
|
||||
}
|
||||
// Injected write operations may preserve only their legacy human-readable error.
|
||||
return error instanceof Error && error.message.includes("No such file or directory");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,44 @@
|
||||
// Sleep utility tests cover timer-safe delay clamping and abort-listener cleanup
|
||||
// for long-running agent waits.
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { isAbortError } from "../../infra/abort-signal.js";
|
||||
import { MAX_TIMER_TIMEOUT_MS } from "../../shared/number-coercion.js";
|
||||
import { sleep } from "./sleep.js";
|
||||
|
||||
describe("agents sleep", () => {
|
||||
it("rejects a pre-aborted zero-duration wait with the canonical abort error", async () => {
|
||||
const controller = new AbortController();
|
||||
const reason = new Error("cancelled");
|
||||
controller.abort(reason);
|
||||
|
||||
const error = await sleep(0, controller.signal).catch((caught: unknown) => caught);
|
||||
|
||||
expect(error).toMatchObject({ name: "AbortError", message: "aborted", cause: reason });
|
||||
expect(isAbortError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a pre-aborted positive-duration wait", async () => {
|
||||
const controller = new AbortController();
|
||||
const reason = new Error("cancelled");
|
||||
controller.abort(reason);
|
||||
|
||||
await expect(sleep(1, controller.signal)).rejects.toMatchObject({
|
||||
name: "AbortError",
|
||||
message: "aborted",
|
||||
cause: reason,
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves a non-aborted zero-duration wait", async () => {
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
try {
|
||||
await expect(sleep(0, new AbortController().signal)).resolves.toBeUndefined();
|
||||
expect(setTimeoutSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
setTimeoutSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("clamps oversized delays before scheduling", async () => {
|
||||
vi.useFakeTimers();
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
@@ -37,4 +71,16 @@ describe("agents sleep", () => {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects cancellation with the canonical abort classification and cause", async () => {
|
||||
const controller = new AbortController();
|
||||
const reason = new Error("stop");
|
||||
const sleeper = sleep(60_000, controller.signal);
|
||||
|
||||
controller.abort(reason);
|
||||
|
||||
const error = await sleeper.catch((caught: unknown) => caught);
|
||||
expect(error).toMatchObject({ name: "AbortError", message: "aborted", cause: reason });
|
||||
expect(isAbortError(error)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,27 +1,17 @@
|
||||
import { sleepWithAbort } from "@openclaw/retry";
|
||||
/**
|
||||
* Sleep helper that respects abort signal.
|
||||
*/
|
||||
import { createAbortError } from "../../infra/abort-signal.js";
|
||||
import { resolveTimerTimeoutMs } from "../../shared/number-coercion.js";
|
||||
|
||||
export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(new Error("Aborted"));
|
||||
return;
|
||||
}
|
||||
|
||||
const onAbort = () => {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error("Aborted"));
|
||||
};
|
||||
const timeout = setTimeout(
|
||||
() => {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
},
|
||||
resolveTimerTimeoutMs(ms, 0, 0),
|
||||
// Cancellation wins even for zero-delay waits so aborted runs cannot
|
||||
// advance into follow-on work such as computer-tool screenshot capture.
|
||||
if (signal?.aborted) {
|
||||
return Promise.reject(
|
||||
createAbortError("aborted", { cause: signal.reason ?? new Error("aborted") }),
|
||||
);
|
||||
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
return sleepWithAbort(resolveTimerTimeoutMs(ms, 0, 0), signal);
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ beforeEach(() => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
vi.unstubAllEnvs();
|
||||
if (originalAgentDir === undefined) {
|
||||
deleteTestEnvValue("OPENCLAW_AGENT_DIR");
|
||||
} else {
|
||||
@@ -53,6 +54,14 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("ensureTool", () => {
|
||||
it("treats trimmed on as the canonical offline opt-in", async () => {
|
||||
vi.stubEnv("OPENCLAW_OFFLINE", " ON ");
|
||||
const { ensureTool } = await import("./tools-manager.js");
|
||||
|
||||
await expect(ensureTool("fd", true)).resolves.toBeUndefined();
|
||||
expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cancels release-check error bodies before releasing guarded fetches", async () => {
|
||||
const { ensureTool } = await import("./tools-manager.js");
|
||||
const release = vi.fn(async () => {});
|
||||
|
||||
@@ -21,6 +21,7 @@ import { pipeline } from "node:stream/promises";
|
||||
import type { ReadableStream as NodeReadableStream } from "node:stream/web";
|
||||
import chalk from "chalk";
|
||||
import { extractArchive } from "../../infra/archive.js";
|
||||
import { isTruthyEnvValue } from "../../infra/env.js";
|
||||
import { fetchWithSsrFGuard } from "../../infra/net/fetch-guard.js";
|
||||
import { APP_NAME, getBinDir } from "../config.js";
|
||||
import { readProviderJsonResponse } from "../provider-http-errors.js";
|
||||
@@ -42,11 +43,7 @@ async function cancelUnreadResponseBody(response: Response): Promise<void> {
|
||||
}
|
||||
|
||||
function isOfflineModeEnabled(): boolean {
|
||||
const value = process.env.OPENCLAW_OFFLINE;
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
return value === "1" || value.toLowerCase() === "true" || value.toLowerCase() === "yes";
|
||||
return isTruthyEnvValue(process.env.OPENCLAW_OFFLINE);
|
||||
}
|
||||
|
||||
interface ToolConfig {
|
||||
|
||||
@@ -4,6 +4,7 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { resolveStateDir } from "../../config/paths.js";
|
||||
import { isMissingPathError } from "../../infra/errors.js";
|
||||
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||
import { runCommandWithTimeout } from "../../process/exec.js";
|
||||
import { withOpenClawStateLease } from "../../state/openclaw-state-lease.js";
|
||||
@@ -237,7 +238,7 @@ async function shouldPreserveOrphanCandidate(
|
||||
try {
|
||||
listedKey = await canonicalPathKey(entry.path);
|
||||
} catch (error) {
|
||||
if (isMissingFileError(error)) {
|
||||
if (isMissingPathError(error)) {
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
@@ -336,21 +337,17 @@ async function runSetupScript(repoRoot: string, worktreePath: string): Promise<v
|
||||
}
|
||||
}
|
||||
|
||||
function isMissingFileError(error: unknown): boolean {
|
||||
return (error as NodeJS.ErrnoException).code === "ENOENT";
|
||||
}
|
||||
|
||||
/**
|
||||
* Sums file sizes without following symlinks, so a link cannot inflate or escape
|
||||
* the worktree. Only ENOENT is tolerated (cleanup races with removals); other
|
||||
* failures propagate so an unreadable tree is never measured as zero bytes.
|
||||
* the worktree. Missing paths are tolerated because cleanup races with removals;
|
||||
* other failures propagate so an unreadable tree is never measured as zero bytes.
|
||||
*/
|
||||
async function directorySizeBytes(root: string): Promise<number> {
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(root, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (isMissingFileError(error)) {
|
||||
if (isMissingPathError(error)) {
|
||||
return 0;
|
||||
}
|
||||
throw error;
|
||||
@@ -364,7 +361,7 @@ async function directorySizeBytes(root: string): Promise<number> {
|
||||
try {
|
||||
total += (await fs.lstat(child)).size;
|
||||
} catch (error) {
|
||||
if (!isMissingFileError(error)) {
|
||||
if (!isMissingPathError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -378,7 +375,7 @@ async function containsGitMarker(root: string, checkoutRoot = false): Promise<bo
|
||||
try {
|
||||
entries = await fs.readdir(root, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (isMissingFileError(error)) {
|
||||
if (isMissingPathError(error)) {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
@@ -431,7 +428,7 @@ async function rawPathExists(target: string | Buffer): Promise<boolean> {
|
||||
await fs.lstat(target);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isMissingFileError(error)) {
|
||||
if (isMissingPathError(error)) {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
@@ -1283,7 +1280,7 @@ export class ManagedWorktreeService {
|
||||
try {
|
||||
managedPaths.add(await canonicalPathKey(record.path));
|
||||
} catch (error) {
|
||||
if (!isMissingFileError(error)) {
|
||||
if (!isMissingPathError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* Owns claim recovery, per-lane serialization, adoption-time complete, retry /
|
||||
* dead-letter disposition, pre-adoption stall watchdog, and optional supersede.
|
||||
*/
|
||||
import { sleepWithAbort } from "@openclaw/retry";
|
||||
import { formatErrorMessage, toErrorObject } from "../../infra/errors.js";
|
||||
import {
|
||||
createIngressDrainOwnerId,
|
||||
@@ -35,7 +36,6 @@ import {
|
||||
DEFAULT_INGRESS_RETRY_MAX_MS,
|
||||
resolveIngressFailureDisposition,
|
||||
resolveIngressRetryDelayMs,
|
||||
sleepIngressRetryDelay,
|
||||
type IngressNonRetryableFailure,
|
||||
type IngressRetryPolicyConfig,
|
||||
} from "./ingress-retry-policy.js";
|
||||
@@ -328,7 +328,7 @@ export function createChannelIngressDrain<
|
||||
log(`completion retry ${attempt} scheduled for event ${displayId}`);
|
||||
}
|
||||
// Abortable sleep: webhook stop aborts options.abortSignal mid-backoff.
|
||||
await sleepIngressRetryDelay(delayMs, options.abortSignal);
|
||||
await sleepWithAbort(delayMs, options.abortSignal, { ref: false });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -119,24 +119,3 @@ export function resolveIngressFailureDisposition(params: {
|
||||
}
|
||||
return { kind: "release", attempt, message };
|
||||
}
|
||||
|
||||
/** Abortable delay used by drain retry/backoff loops. */
|
||||
export function sleepIngressRetryDelay(ms: number, abortSignal?: AbortSignal): Promise<void> {
|
||||
const abortError = () =>
|
||||
abortSignal?.reason instanceof Error ? abortSignal.reason : new Error("ingress-aborted");
|
||||
if (abortSignal?.aborted) {
|
||||
return Promise.reject(abortError());
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
abortSignal?.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
}, ms);
|
||||
timer.unref?.();
|
||||
const onAbort = () => {
|
||||
clearTimeout(timer);
|
||||
reject(abortError());
|
||||
};
|
||||
abortSignal?.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import path from "node:path";
|
||||
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { isMissingPathError } from "../infra/errors.js";
|
||||
import { createPluginStateKeyedStore } from "../plugin-state/plugin-state-store.js";
|
||||
import { archiveLegacyStateSource } from "../plugins/doctor-state-migration-fs.js";
|
||||
|
||||
@@ -55,13 +56,6 @@ type StatePathInspection =
|
||||
| { status: "missing" }
|
||||
| { status: "unsafe"; warning: string };
|
||||
|
||||
function isMissingPathError(error: unknown): boolean {
|
||||
if (!error || typeof error !== "object" || !("code" in error)) {
|
||||
return false;
|
||||
}
|
||||
return error.code === "ENOENT" || error.code === "ENOTDIR";
|
||||
}
|
||||
|
||||
async function inspectStatePath(filePath: string, label: string): Promise<StatePathInspection> {
|
||||
try {
|
||||
return (await fs.stat(filePath)).isFile()
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from "../agents/agent-scope.js";
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { isMissingPathError } from "../infra/errors.js";
|
||||
import { removePathWithinRoot } from "../infra/fs-safe-remove.js";
|
||||
import { pathExists, root, type Root } from "../infra/fs-safe.js";
|
||||
import { normalizeAgentId, resolveAgentIdFromSessionKey } from "../routing/session-key.js";
|
||||
@@ -36,11 +37,6 @@ type MigrationResult = {
|
||||
migrated: number;
|
||||
};
|
||||
|
||||
function isNotFoundError(error: unknown): boolean {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
return code === "not-found" || code === "ENOENT";
|
||||
}
|
||||
|
||||
async function readJson(rootDir: Root, relativePath: string, maxBytes: number): Promise<unknown> {
|
||||
const read = await rootDir.read(relativePath, {
|
||||
hardlinks: "reject",
|
||||
@@ -107,7 +103,7 @@ async function readLegacyRollback(
|
||||
}
|
||||
return rollback.value;
|
||||
} catch (error) {
|
||||
if (isNotFoundError(error)) {
|
||||
if (isMissingPathError(error)) {
|
||||
return undefined;
|
||||
}
|
||||
throw error;
|
||||
@@ -240,7 +236,7 @@ export async function migrateLegacySkillWorkshopProposals(params: {
|
||||
});
|
||||
migrated += 1;
|
||||
} catch (error) {
|
||||
if (isNotFoundError(error)) {
|
||||
if (isMissingPathError(error)) {
|
||||
if (await readSkillProposal(proposalId, { env }, {}, { reconcile: false })) {
|
||||
continue;
|
||||
}
|
||||
@@ -250,7 +246,7 @@ export async function migrateLegacySkillWorkshopProposals(params: {
|
||||
}
|
||||
await removePathWithinRoot({ rootDir: stateDir, relativePath: MANIFEST_PATH }).catch(
|
||||
(error: unknown) => {
|
||||
if (!isNotFoundError(error)) {
|
||||
if (!isMissingPathError(error)) {
|
||||
warnings.push(`Failed to remove legacy Skill Workshop proposal index: ${String(error)}`);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
import type { SessionEntry } from "../config/sessions/types.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { HealthFinding, HealthRepairEffect } from "../flows/health-checks.js";
|
||||
import { safeRealpathSync } from "../infra/boundary-path.js";
|
||||
import { resolveRequiredHomeDir } from "../infra/home-dir.js";
|
||||
import {
|
||||
loadLegacySessionStore,
|
||||
@@ -354,13 +355,7 @@ function isPathUnderRoot(targetPath: string, rootPath: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function tryResolveRealPath(targetPath: string): string | null {
|
||||
try {
|
||||
return fs.realpathSync(targetPath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const tryResolveRealPath = safeRealpathSync;
|
||||
|
||||
function resolvePathThroughExistingAncestor(
|
||||
targetPath: string,
|
||||
|
||||
@@ -17,6 +17,7 @@ import { expectDefined } from "@openclaw/normalization-core";
|
||||
import { canUseRootFileOpen, openRootFileSync } from "../infra/boundary-file-read.js";
|
||||
import { resolvePathViaExistingAncestorSync } from "../infra/boundary-path.js";
|
||||
import { mergeDeep as mergeDeepValues } from "../infra/deep-merge.js";
|
||||
import { isMissingPathError } from "../infra/errno.js";
|
||||
import { isPathInside } from "../security/scan-paths.js";
|
||||
import { isPlainObject } from "../utils.js";
|
||||
import { parseJsonWithJson5Fallback } from "../utils/parse-json-compat.js";
|
||||
@@ -353,7 +354,7 @@ class IncludeProcessor {
|
||||
if (err instanceof ConfigIncludeError) {
|
||||
throw err;
|
||||
}
|
||||
if (isNotFoundError(err)) {
|
||||
if (isMissingPathError(err)) {
|
||||
// File doesn't exist yet - lexical containment check above is sufficient.
|
||||
return { resolvedPath: normalized, root: lexicalMatch };
|
||||
}
|
||||
@@ -476,15 +477,6 @@ function createConfigIncludeBoundary(
|
||||
};
|
||||
}
|
||||
|
||||
function isNotFoundError(error: unknown): boolean {
|
||||
return Boolean(
|
||||
error &&
|
||||
typeof error === "object" &&
|
||||
"code" in error &&
|
||||
(error as { code?: unknown }).code === "ENOENT",
|
||||
);
|
||||
}
|
||||
|
||||
export function readConfigIncludeFileWithGuards(params: IncludeFileReadParams): string {
|
||||
const ioFs = params.ioFs ?? fs;
|
||||
const maxBytes = params.maxBytes ?? MAX_INCLUDE_FILE_BYTES;
|
||||
|
||||
@@ -4,7 +4,7 @@ import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { expectDefined } from "@openclaw/normalization-core";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { formatErrorMessage, isMissingPathError } from "../infra/errors.js";
|
||||
import { withFileLock } from "../infra/file-lock.js";
|
||||
import { root as createFsRoot, type Root as FsSafeRoot } from "../infra/fs-safe.js";
|
||||
import { KeyedAsyncQueue } from "../plugin-sdk/keyed-async-queue.js";
|
||||
@@ -415,11 +415,6 @@ type RootBoundIncludeFile = {
|
||||
root: FsSafeRoot;
|
||||
};
|
||||
|
||||
function isMissingFileError(error: unknown): boolean {
|
||||
const code = (error as { code?: unknown } | null)?.code;
|
||||
return code === "ENOENT" || code === "not-found";
|
||||
}
|
||||
|
||||
function resolveRootBoundRelativePath(target: RootBoundIncludeFile, absolutePath: string): string {
|
||||
const relativePath = path.relative(target.root.rootReal, path.resolve(absolutePath));
|
||||
const firstSegment = relativePath.split(path.sep)[0];
|
||||
@@ -496,7 +491,7 @@ async function readRootBoundFileRawIfExists(target: RootBoundIncludeFile): Promi
|
||||
try {
|
||||
return await target.root.readText(target.relativePath);
|
||||
} catch (error) {
|
||||
if (isMissingFileError(error)) {
|
||||
if (isMissingPathError(error)) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
@@ -508,7 +503,7 @@ async function assertRootConfigStillMatchesSnapshot(snapshot: ConfigFileSnapshot
|
||||
try {
|
||||
currentRaw = await fs.readFile(snapshot.path, "utf-8");
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") {
|
||||
if (!isMissingPathError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -541,7 +536,7 @@ async function rollbackJsonFileWriteIfUnchanged(params: {
|
||||
try {
|
||||
await params.target.root.remove(params.target.relativePath);
|
||||
} catch (error) {
|
||||
if (!isMissingFileError(error)) {
|
||||
if (!isMissingPathError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
|
||||
import { safeRealpathSync } from "../../infra/boundary-path.js";
|
||||
import { expandHomePrefix, resolveRequiredHomeDir } from "../../infra/home-dir.js";
|
||||
import { normalizeAgentId } from "../../routing/session-key.js";
|
||||
import { resolveStateDir } from "../paths.js";
|
||||
@@ -195,14 +196,6 @@ function resolveStructuralSessionFallbackPath(
|
||||
return path.normalize(path.resolve(candidateAbsPath));
|
||||
}
|
||||
|
||||
function safeRealpathSync(filePath: string): string | undefined {
|
||||
try {
|
||||
return fs.realpathSync(filePath);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePathWithinSessionsDir(
|
||||
sessionsDir: string,
|
||||
candidate: string,
|
||||
|
||||
@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
|
||||
import { sanitizeForLog } from "../../packages/terminal-core/src/ansi.js";
|
||||
import { isMissingPathError } from "../infra/errors.js";
|
||||
import { execFileUtf8 } from "./exec-file.js";
|
||||
import {
|
||||
execLaunchctl,
|
||||
@@ -32,10 +33,6 @@ function formatUnknownError(error: unknown): string {
|
||||
return truncateUtf16Safe(sanitizeForLog(raw), 500);
|
||||
}
|
||||
|
||||
function isMissingPathError(error: unknown): boolean {
|
||||
return (error as NodeJS.ErrnoException | undefined)?.code === "ENOENT";
|
||||
}
|
||||
|
||||
function quotePosixArgument(value: string): string {
|
||||
return /^[A-Za-z0-9_@%+=:,./-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
|
||||
import { sanitizeForLog } from "../../packages/terminal-core/src/ansi.js";
|
||||
import { isMissingPathError } from "../infra/errors.js";
|
||||
import { execFileUtf8 } from "./exec-file.js";
|
||||
|
||||
type SystemSystemdOwnership =
|
||||
@@ -23,11 +24,6 @@ function formatUnknownError(error: unknown): string {
|
||||
return truncateUtf16Safe(sanitizeForLog(raw), 500);
|
||||
}
|
||||
|
||||
function isMissingPathError(error: unknown): boolean {
|
||||
const code = (error as NodeJS.ErrnoException | undefined)?.code;
|
||||
return code === "ENOENT" || code === "ENOTDIR";
|
||||
}
|
||||
|
||||
function quotePosixArgument(value: string): string {
|
||||
return /^[A-Za-z0-9_@%+=:,./-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
|
||||
}
|
||||
|
||||
@@ -10,18 +10,20 @@ import {
|
||||
} from "./doctor-health-contribution-utils.js";
|
||||
import type { HealthCheckContext, HealthFinding } from "./health-checks.js";
|
||||
|
||||
function isTruthyEnvValue(value: string | undefined): boolean {
|
||||
function isExplicitOptOutEnvValue(value: string | undefined): boolean {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
// Update handoff predates canonical opt-in flags: every non-false value means the
|
||||
// parent opted in, so preserve its broad acceptance until that protocol is retired.
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return normalized !== "" && normalized !== "0" && normalized !== "false" && normalized !== "no";
|
||||
}
|
||||
|
||||
function shouldSkipLegacyUpdateDoctorConfigWrite(env: NodeJS.ProcessEnv): boolean {
|
||||
return (
|
||||
isTruthyEnvValue(env.OPENCLAW_UPDATE_IN_PROGRESS) &&
|
||||
!isTruthyEnvValue(env[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV])
|
||||
isExplicitOptOutEnvValue(env.OPENCLAW_UPDATE_IN_PROGRESS) &&
|
||||
!isExplicitOptOutEnvValue(env[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV])
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3698,6 +3698,14 @@ describe("doctor health contributions", () => {
|
||||
},
|
||||
shouldWrite: true,
|
||||
},
|
||||
{
|
||||
name: "legacy protocol's broad parent opt-in",
|
||||
env: {
|
||||
OPENCLAW_UPDATE_IN_PROGRESS: "enabled",
|
||||
OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE: "supported",
|
||||
},
|
||||
shouldWrite: true,
|
||||
},
|
||||
{
|
||||
name: "falsey update env values",
|
||||
env: { OPENCLAW_UPDATE_IN_PROGRESS: "0" },
|
||||
|
||||
@@ -451,6 +451,8 @@ function escapesBase(baseDir: string, candidate: string): boolean {
|
||||
|
||||
function safeRealpathSync(candidate: string): string | null {
|
||||
try {
|
||||
// Hook containment prefers native canonicalization when Node exposes it.
|
||||
// Keep the plain fallback only for runtimes without the native entrypoint.
|
||||
const nativeRealpath = fs.realpathSync.native as ((path: string) => string) | undefined;
|
||||
return nativeRealpath ? nativeRealpath(candidate) : fs.realpathSync(candidate);
|
||||
} catch {
|
||||
|
||||
@@ -72,6 +72,7 @@ import { purgeAgentSessionStoreEntries } from "../../config/sessions.js";
|
||||
import { resolveSessionTranscriptsDirForAgent } from "../../config/sessions/paths.js";
|
||||
import type { IdentityConfig } from "../../config/types.base.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { isMissingPathError } from "../../infra/errors.js";
|
||||
import { withAgentExecApprovalsRemoved } from "../../infra/exec-approvals.js";
|
||||
import { root, FsSafeError, type ReadResult } from "../../infra/fs-safe.js";
|
||||
import { isPathInside } from "../../infra/path-guards.js";
|
||||
@@ -387,13 +388,6 @@ async function statAgentCleanupPath(cleanupPath: AgentDeleteCleanupPath) {
|
||||
}
|
||||
}
|
||||
|
||||
function isMissingCleanupPathError(error: unknown): boolean {
|
||||
return (
|
||||
(error instanceof FsSafeError && error.code === "not-found") ||
|
||||
(error as NodeJS.ErrnoException).code === "ENOENT"
|
||||
);
|
||||
}
|
||||
|
||||
async function removeAgentPath(
|
||||
cleanupPath: AgentDeleteCleanupPath,
|
||||
): Promise<AgentDeletePathOutcome> {
|
||||
@@ -405,7 +399,7 @@ async function removeAgentPath(
|
||||
if (error instanceof AgentCleanupIdentityMismatchError) {
|
||||
return { skipped: { path: pathname, reason: error.message } };
|
||||
}
|
||||
return isMissingCleanupPathError(error)
|
||||
return isMissingPathError(error)
|
||||
? { removed: { path: pathname, method: "missing" } }
|
||||
: cleanupFailure(pathname, error);
|
||||
}
|
||||
@@ -415,14 +409,14 @@ async function removeAgentPath(
|
||||
await movePathToTrash(trashPath);
|
||||
return { removed: { path: pathname, method: "trash" } };
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
if (!isMissingPathError(error)) {
|
||||
return cleanupFailure(pathname, error);
|
||||
}
|
||||
try {
|
||||
await statAgentCleanupPath(cleanupPath);
|
||||
return cleanupFailure(pathname, error);
|
||||
} catch (statError) {
|
||||
return isMissingCleanupPathError(statError)
|
||||
return isMissingPathError(statError)
|
||||
? { removed: { path: pathname, method: "missing" } }
|
||||
: cleanupFailure(pathname, statError);
|
||||
}
|
||||
@@ -450,14 +444,14 @@ async function resolveAgentDeleteCleanupTarget(pathname: string): Promise<string
|
||||
try {
|
||||
return path.resolve(await fs.realpath(candidate), ...missingSuffix);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
if (!isMissingPathError(error)) {
|
||||
throw error;
|
||||
}
|
||||
let candidateStat: Awaited<ReturnType<typeof fs.lstat>> | undefined;
|
||||
try {
|
||||
candidateStat = await fs.lstat(candidate);
|
||||
} catch (statError) {
|
||||
if ((statError as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
if (!isMissingPathError(statError)) {
|
||||
throw statError;
|
||||
}
|
||||
}
|
||||
@@ -538,7 +532,7 @@ async function prepareAgentDeleteCleanupPaths(
|
||||
try {
|
||||
sourceStat = await fs.lstat(pathname);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
if (!isMissingPathError(error)) {
|
||||
preparationError ??= error;
|
||||
}
|
||||
}
|
||||
@@ -547,7 +541,7 @@ async function prepareAgentDeleteCleanupPaths(
|
||||
try {
|
||||
targetStat = await fs.lstat(resolvedPath);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
if (!isMissingPathError(error)) {
|
||||
preparationError ??= error;
|
||||
}
|
||||
targetStat = undefined;
|
||||
@@ -817,7 +811,7 @@ async function readWorkspaceFileContent(
|
||||
});
|
||||
return safeRead.buffer.toString("utf-8");
|
||||
} catch (err) {
|
||||
if (err instanceof FsSafeError && err.code === "not-found") {
|
||||
if (isMissingPathError(err)) {
|
||||
return undefined;
|
||||
}
|
||||
throw err;
|
||||
@@ -1337,7 +1331,7 @@ export const agentsHandlers: GatewayRequestHandlers = {
|
||||
try {
|
||||
await statAgentCleanupPath(cleanupPath);
|
||||
} catch (error) {
|
||||
if (isMissingCleanupPathError(error)) {
|
||||
if (isMissingPathError(error)) {
|
||||
replacementPresent = false;
|
||||
} else if (!(error instanceof AgentCleanupIdentityMismatchError)) {
|
||||
note = "completed cleanup path could not be verified; replacement preserved";
|
||||
@@ -1524,7 +1518,7 @@ export const agentsHandlers: GatewayRequestHandlers = {
|
||||
nonBlockingRead: true,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof FsSafeError && err.code === "not-found") {
|
||||
if (isMissingPathError(err)) {
|
||||
respondWorkspaceFileMissing({ respond, agentId, workspaceDir, name, filePath });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import path from "node:path";
|
||||
import { sanitizeForLog } from "../../packages/terminal-core/src/ansi.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { openRootFile } from "../infra/boundary-file-read.js";
|
||||
import { safeRealpathSync } from "../infra/boundary-path.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { resolveGlobalSingleton } from "../shared/global-singleton.js";
|
||||
@@ -127,7 +128,7 @@ export async function loadInternalHooks(
|
||||
|
||||
for (const entry of eligible) {
|
||||
try {
|
||||
const hookBaseDir = resolveExistingRealpath(entry.hook.baseDir);
|
||||
const hookBaseDir = safeRealpathSync(entry.hook.baseDir);
|
||||
if (!hookBaseDir) {
|
||||
log.error(
|
||||
`Hook '${safeLogValue(entry.hook.name)}' base directory is no longer readable: ${safeLogValue(entry.hook.baseDir)}`,
|
||||
@@ -225,14 +226,14 @@ export async function loadInternalHooks(
|
||||
}
|
||||
const baseDir = path.resolve(workspaceDir);
|
||||
const modulePath = path.resolve(baseDir, rawModule);
|
||||
const baseDirReal = resolveExistingRealpath(baseDir);
|
||||
const baseDirReal = safeRealpathSync(baseDir);
|
||||
if (!baseDirReal) {
|
||||
log.error(
|
||||
`Workspace directory is no longer readable while loading hooks: ${safeLogValue(baseDir)}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const modulePathSafe = resolveExistingRealpath(modulePath);
|
||||
const modulePathSafe = safeRealpathSync(modulePath);
|
||||
if (!modulePathSafe) {
|
||||
log.error(
|
||||
`Handler module path could not be resolved with realpath: ${safeLogValue(rawModule)}`,
|
||||
@@ -303,11 +304,3 @@ export async function loadInternalHooks(
|
||||
|
||||
return loadedCount;
|
||||
}
|
||||
|
||||
function resolveExistingRealpath(value: string): string | null {
|
||||
try {
|
||||
return fs.realpathSync(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
// Exposes boundary path resolution helpers with fs-safe defaults.
|
||||
import "./fs-safe-defaults.js";
|
||||
import path from "node:path";
|
||||
import { safeRealpathSync } from "@openclaw/fs-safe/path";
|
||||
export { safeRealpathSync } from "@openclaw/fs-safe/path";
|
||||
|
||||
/** Returns a canonical path when resolvable, otherwise an absolute lexical path. */
|
||||
export function resolveRealpathOrAbsolute(value: string): string {
|
||||
return safeRealpathSync(value) ?? path.resolve(value);
|
||||
}
|
||||
|
||||
// Boundary path resolution keeps alias expansion and realpath checks in one
|
||||
// shared contract before file IO happens.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// Normalizes env flag values and logs env warnings lazily.
|
||||
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
|
||||
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
|
||||
import type { SubsystemLogger } from "../logging/subsystem.js";
|
||||
import { createLazyPromise } from "../shared/lazy-runtime.js";
|
||||
import { parseBooleanValue } from "../utils/boolean.js";
|
||||
export { isFastTestRuntimeEnv, isVitestRuntimeEnv } from "./test-runtime-env.js";
|
||||
|
||||
let log: SubsystemLogger | null = null;
|
||||
@@ -95,18 +95,7 @@ export function resolveEnvNormalizationKeys(key: string): readonly string[] {
|
||||
|
||||
/** Interprets common human/operator truthy env strings. */
|
||||
export function isTruthyEnvValue(value?: string): boolean {
|
||||
if (typeof value !== "string") {
|
||||
return false;
|
||||
}
|
||||
switch (normalizeLowercaseStringOrEmpty(value)) {
|
||||
case "1":
|
||||
case "on":
|
||||
case "true":
|
||||
case "yes":
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
return parseBooleanValue(value) === true;
|
||||
}
|
||||
|
||||
/** Applies process-wide env normalization before runtime configuration is read. */
|
||||
|
||||
16
src/infra/errno.ts
Normal file
16
src/infra/errno.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/** Type guard for NodeJS.ErrnoException (any object with a `code` property). */
|
||||
export function isErrno(err: unknown): err is NodeJS.ErrnoException {
|
||||
return Boolean(err && typeof err === "object" && "code" in err);
|
||||
}
|
||||
|
||||
/** Checks whether an errno-shaped value has the exact code. */
|
||||
export function hasErrnoCode(err: unknown, code: string): boolean {
|
||||
return isErrno(err) && err.code === code;
|
||||
}
|
||||
|
||||
/** Classifies missing filesystem paths across Node and fs-safe boundaries. */
|
||||
export function isMissingPathError(err: unknown): boolean {
|
||||
return (
|
||||
hasErrnoCode(err, "ENOENT") || hasErrnoCode(err, "ENOTDIR") || hasErrnoCode(err, "not-found")
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
formatUncaughtError,
|
||||
hasErrnoCode,
|
||||
isErrno,
|
||||
isMissingPathError,
|
||||
readErrorName,
|
||||
} from "./errors.js";
|
||||
|
||||
@@ -100,6 +101,18 @@ describe("error helpers", () => {
|
||||
expect(isErrno("busy")).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["ENOENT", "ENOTDIR", "not-found"])(
|
||||
"classifies %s as a missing path without requiring Error identity",
|
||||
(code) => {
|
||||
expect(isMissingPathError({ code })).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it("does not classify other fs-safe or errno failures as missing paths", () => {
|
||||
expect(isMissingPathError({ code: "path-alias" })).toBe(false);
|
||||
expect(isMissingPathError(new Error("ENOENT"))).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ value: 123n, expected: "123" },
|
||||
{ value: false, expected: "false" },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Normalizes error objects for codes, names, messages, and redacted logs.
|
||||
import { formatErrorMessage as formatSharedErrorMessage } from "@openclaw/normalization-core/error-coercion";
|
||||
import { redactSensitiveText } from "../logging/redact.js";
|
||||
export { hasErrnoCode, isErrno, isMissingPathError } from "./errno.js";
|
||||
|
||||
export function extractErrorCode(err: unknown): string | undefined {
|
||||
if (!err || typeof err !== "object") {
|
||||
@@ -53,20 +54,6 @@ export function collectErrorGraphCandidates(
|
||||
return candidates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for NodeJS.ErrnoException (any error with a `code` property).
|
||||
*/
|
||||
export function isErrno(err: unknown): err is NodeJS.ErrnoException {
|
||||
return Boolean(err && typeof err === "object" && "code" in err);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error has a specific errno code.
|
||||
*/
|
||||
export function hasErrnoCode(err: unknown, code: string): boolean {
|
||||
return isErrno(err) && err.code === code;
|
||||
}
|
||||
|
||||
export function formatErrorMessage(err: unknown): string {
|
||||
return formatSharedErrorMessage(err, { redact: redactSensitiveText });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Parses execution allowlist patterns for approval policy checks.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { safeRealpathSync } from "@openclaw/fs-safe/path";
|
||||
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
|
||||
import { escapeRegExp as escapeRegExpLiteral } from "../shared/regexp.js";
|
||||
import { expandHomePrefix } from "./home-dir.js";
|
||||
@@ -25,14 +25,6 @@ function normalizeMatchTarget(value: string): string {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function tryRealpath(value: string): string | null {
|
||||
try {
|
||||
return fs.realpathSync(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function hasDotPathSegment(value: string): boolean {
|
||||
return value
|
||||
.replace(/\\/g, "/")
|
||||
@@ -97,8 +89,8 @@ export function matchesExecAllowlistPattern(pattern: string, target: string): bo
|
||||
let normalizedPattern = expanded;
|
||||
let normalizedTarget = target;
|
||||
if (process.platform === "win32" && !hasWildcard) {
|
||||
normalizedPattern = tryRealpath(expanded) ?? expanded;
|
||||
normalizedTarget = tryRealpath(target) ?? target;
|
||||
normalizedPattern = safeRealpathSync(expanded) ?? expanded;
|
||||
normalizedTarget = safeRealpathSync(target) ?? target;
|
||||
}
|
||||
normalizedPattern = normalizeMatchTarget(normalizedPattern);
|
||||
normalizedTarget = normalizeMatchTarget(normalizedTarget);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Resolves command executables and wrapper policy paths for exec approvals.
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { safeRealpathSync } from "@openclaw/fs-safe/path";
|
||||
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
|
||||
import { matchesExecAllowlistPattern } from "./exec-allowlist-pattern.js";
|
||||
import type { ExecAllowlistEntry } from "./exec-approvals.types.js";
|
||||
@@ -51,14 +51,7 @@ function parseFirstToken(command: string): string | null {
|
||||
}
|
||||
|
||||
function tryResolveRealpath(filePath: string | undefined): string | undefined {
|
||||
if (!filePath) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return fs.realpathSync(filePath);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return filePath ? (safeRealpathSync(filePath) ?? undefined) : undefined;
|
||||
}
|
||||
|
||||
function buildExecutableResolution(
|
||||
|
||||
@@ -3,6 +3,7 @@ import "./fs-safe-defaults.js";
|
||||
import path from "node:path";
|
||||
import { FsSafeError } from "@openclaw/fs-safe/errors";
|
||||
import { root as fsSafeRoot, type Root } from "@openclaw/fs-safe/root";
|
||||
import { isMissingPathError } from "./errors.js";
|
||||
|
||||
async function listDirectoryEntries(root: Root, relativePath: string) {
|
||||
return await root.list(relativePath, { withFileTypes: true });
|
||||
@@ -18,12 +19,7 @@ function compareDirectoryEntryNames(left: DirectoryEntry, right: DirectoryEntry)
|
||||
}
|
||||
|
||||
function isNotFoundError(error: unknown): boolean {
|
||||
const code = (error as NodeJS.ErrnoException | undefined)?.code;
|
||||
return (
|
||||
code === "not-found" ||
|
||||
code === "ENOENT" ||
|
||||
findPathAliasFilesystemCause(error)?.code === "ENOENT"
|
||||
);
|
||||
return isMissingPathError(error) || isMissingPathError(findPathAliasFilesystemCause(error));
|
||||
}
|
||||
|
||||
function findPathAliasFilesystemCause(error: unknown): NodeJS.ErrnoException | undefined {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
|
||||
import { isMissingPathError } from "./errors.js";
|
||||
import { readFileWindowFullySync } from "./file-read.js";
|
||||
import { resolveGitHeadPath } from "./git-root.js";
|
||||
import { pruneMapToMaxSize } from "./map-size.js";
|
||||
@@ -33,14 +34,6 @@ type CommitMetadataReaders = {
|
||||
readPackageJsonCommit?: () => string | null;
|
||||
};
|
||||
|
||||
function isMissingPathError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) {
|
||||
return false;
|
||||
}
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
return code === "ENOENT" || code === "ENOTDIR";
|
||||
}
|
||||
|
||||
const resolveCommitSearchDir = (options: { cwd?: string; moduleUrl?: string }) => {
|
||||
if (options.cwd) {
|
||||
return path.resolve(options.cwd);
|
||||
|
||||
@@ -167,6 +167,8 @@ function getRedirectVisitKey(url: string, init: RequestInit | undefined): string
|
||||
}
|
||||
|
||||
function isTruthyEnvValue(value: string | undefined): boolean {
|
||||
// This flag relaxes an outbound-network security boundary. Keep exact lowercase
|
||||
// tokens so whitespace or case variation cannot accidentally widen access.
|
||||
return value === "1" || value === "true" || value === "yes" || value === "on";
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
DiagnosticMemoryPressureEvent,
|
||||
DiagnosticMemoryUsage,
|
||||
} from "../infra/diagnostic-events.js";
|
||||
import { isMissingPathError } from "../infra/errors.js";
|
||||
import { registerFatalErrorHook } from "../infra/fatal-error-hooks.js";
|
||||
import { parseStrictNonNegativeInteger } from "../infra/parse-finite-number.js";
|
||||
import { replaceFileAtomicSync } from "../infra/replace-file.js";
|
||||
@@ -248,15 +249,6 @@ function isBundleFile(name: string): boolean {
|
||||
return name.startsWith(BUNDLE_PREFIX) && name.endsWith(BUNDLE_SUFFIX);
|
||||
}
|
||||
|
||||
function isMissingFileError(error: unknown): boolean {
|
||||
return (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"code" in error &&
|
||||
(error as { code?: unknown }).code === "ENOENT"
|
||||
);
|
||||
}
|
||||
|
||||
function readObject(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error(`Invalid stability bundle: ${label} must be an object`);
|
||||
@@ -1234,7 +1226,7 @@ function listDiagnosticStabilityBundleFilesSync(
|
||||
})
|
||||
.toSorted((a, b) => b.mtimeMs - a.mtimeMs || b.path.localeCompare(a.path));
|
||||
} catch (error) {
|
||||
if (isMissingFileError(error)) {
|
||||
if (isMissingPathError(error)) {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { parseConfigJson5 } from "../config/io.js";
|
||||
import { resolveConfigPath, resolveStateDir } from "../config/paths.js";
|
||||
import { redactConfigObject } from "../config/redact-snapshot.js";
|
||||
import { buildConfigSchema } from "../config/schema.js";
|
||||
import { isMissingPathError } from "../infra/errors.js";
|
||||
import { resolveHomeRelativePath } from "../infra/home-dir.js";
|
||||
import { readRegularFileSync } from "../infra/regular-file.js";
|
||||
import { VERSION } from "../version.js";
|
||||
@@ -324,13 +325,6 @@ function configShapeReadFailure(params: {
|
||||
return shape;
|
||||
}
|
||||
|
||||
function isMissingPathError(error: unknown): boolean {
|
||||
if (!error || typeof error !== "object" || !("code" in error)) {
|
||||
return false;
|
||||
}
|
||||
return error.code === "ENOENT" || error.code === "ENOTDIR";
|
||||
}
|
||||
|
||||
function configReadErrorMessage(error: unknown, stat?: fs.Stats): string | undefined {
|
||||
if (!stat && isMissingPathError(error)) {
|
||||
return undefined;
|
||||
|
||||
80
src/media/store.retry.test.ts
Normal file
80
src/media/store.retry.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
// Media store retry tests cover the exact directory-recreation recovery boundary.
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { importFreshModule } from "openclaw/plugin-sdk/test-fixtures";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
|
||||
import { FsSafeError } from "../infra/fs-safe.js";
|
||||
|
||||
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
||||
|
||||
afterEach(() => {
|
||||
vi.doUnmock("../infra/file-store.js");
|
||||
vi.unstubAllEnvs();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
function errnoError(code: string): Error {
|
||||
return Object.assign(new Error(code), { code });
|
||||
}
|
||||
|
||||
describe("media store directory recreation", () => {
|
||||
it.each([
|
||||
{
|
||||
name: "ENOTDIR",
|
||||
error: () => errnoError("ENOTDIR"),
|
||||
shouldRetry: false,
|
||||
},
|
||||
{
|
||||
name: "standalone fs-safe not-found",
|
||||
error: () => new FsSafeError("not-found", "media target not found"),
|
||||
shouldRetry: false,
|
||||
},
|
||||
{
|
||||
name: "fs-safe not-found wrapping ENOENT",
|
||||
error: () =>
|
||||
new FsSafeError("not-found", "media target not found", {
|
||||
cause: errnoError("ENOENT"),
|
||||
}),
|
||||
shouldRetry: true,
|
||||
},
|
||||
])("surfaces or retries $name according to its exact cause", async ({ error, shouldRetry }) => {
|
||||
const stateDir = tempDirs.make("openclaw-media-retry-");
|
||||
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
|
||||
const segment = `retry-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const injectedError = error();
|
||||
let writeAttempts = 0;
|
||||
vi.doMock("../infra/file-store.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../infra/file-store.js")>();
|
||||
return {
|
||||
...actual,
|
||||
fileStore: (options: Parameters<typeof actual.fileStore>[0]) => {
|
||||
const actualStore = actual.fileStore(options);
|
||||
return {
|
||||
...actualStore,
|
||||
write: async (...args: Parameters<typeof actualStore.write>) => {
|
||||
if (args[0].includes(`${segment}${path.sep}`) && writeAttempts++ === 0) {
|
||||
throw injectedError;
|
||||
}
|
||||
return await actualStore.write(...args);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const store = await importFreshModule<typeof import("./store.js")>(
|
||||
import.meta.url,
|
||||
`./store.js?scope=retry-boundary-${segment}`,
|
||||
);
|
||||
const result = store.saveMediaBuffer(Buffer.from("voice"), "audio/ogg", segment);
|
||||
if (shouldRetry) {
|
||||
const saved = await result;
|
||||
await expect(fs.stat(saved.path)).resolves.toMatchObject({ size: 5 });
|
||||
expect(writeAttempts).toBe(2);
|
||||
return;
|
||||
}
|
||||
await expect(result).rejects.toBe(injectedError);
|
||||
expect(writeAttempts).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -171,7 +171,9 @@ function findErrorWithCode(err: unknown, code: string): NodeJS.ErrnoException |
|
||||
return findErrorWithCode(err.cause, code);
|
||||
}
|
||||
|
||||
function isMissingPathError(err: unknown): boolean {
|
||||
function hasRecoverableMissingMediaDirCause(err: unknown): boolean {
|
||||
// Recursive mkdir repairs only the ENOENT race where cleanup pruned the directory.
|
||||
// Structural ENOTDIR and generic fs-safe absence remain terminal diagnostics.
|
||||
return findErrorWithCode(err, "ENOENT") !== undefined;
|
||||
}
|
||||
|
||||
@@ -188,7 +190,7 @@ async function retryAfterRecreatingDir<T>(dir: string, run: () => Promise<T>): P
|
||||
attempts: 2,
|
||||
minDelayMs: 0,
|
||||
maxDelayMs: 0,
|
||||
shouldRetry: isMissingPathError,
|
||||
shouldRetry: hasRecoverableMissingMediaDirCause,
|
||||
onRetry: async () => {
|
||||
// Cleanup can prune the directory between mkdir and file open. Recreate
|
||||
// it once; further failures remain terminal instead of looping.
|
||||
|
||||
@@ -619,7 +619,7 @@ describe("loadBundledEntryExportSync", () => {
|
||||
expect(result.stderr).toMatch(/sourceLoaderCallMs=0(?:\.0+)?(?:\s|$)/u);
|
||||
});
|
||||
|
||||
it("can disable source-tree fallback for dist bundled entry checks", () => {
|
||||
it("preserves presence-based source fallback disable semantics and cache modes", () => {
|
||||
stubPluginModuleLoaderJitiFactory(
|
||||
vi.fn(() => vi.fn(() => ({ sentinel: 42 }))) as unknown as PluginModuleLoaderFactory,
|
||||
);
|
||||
@@ -639,20 +639,29 @@ describe("loadBundledEntryExportSync", () => {
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(
|
||||
const loadSecretContract = () =>
|
||||
loadBundledEntryExportSync<number>(pathToFileURL(importerPath).href, {
|
||||
specifier: "./src/secret-contract.js",
|
||||
exportName: "sentinel",
|
||||
}),
|
||||
).toBe(42);
|
||||
});
|
||||
|
||||
vi.stubEnv("OPENCLAW_DISABLE_BUNDLED_ENTRY_SOURCE_FALLBACK", "1");
|
||||
expect(loadSecretContract()).toBe(42);
|
||||
|
||||
expect(() =>
|
||||
loadBundledEntryExportSync<number>(pathToFileURL(importerPath).href, {
|
||||
specifier: "./src/secret-contract.js",
|
||||
exportName: "sentinel",
|
||||
}),
|
||||
).toThrow(`resolved "${path.join(pluginRoot, "src", "secret-contract.js")}"`);
|
||||
vi.stubEnv("OPENCLAW_DISABLE_BUNDLED_ENTRY_SOURCE_FALLBACK", "enabled");
|
||||
expect(loadSecretContract).toThrow(
|
||||
`resolved "${path.join(pluginRoot, "src", "secret-contract.js")}"`,
|
||||
);
|
||||
|
||||
for (const value of ["", " ", "off", "no", " ON ", "arbitrary"]) {
|
||||
vi.stubEnv("OPENCLAW_DISABLE_BUNDLED_ENTRY_SOURCE_FALLBACK", value);
|
||||
expect(loadSecretContract).toThrow(
|
||||
`resolved "${path.join(pluginRoot, "src", "secret-contract.js")}"`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const value of ["0", " 0 ", "false", " FALSE "]) {
|
||||
vi.stubEnv("OPENCLAW_DISABLE_BUNDLED_ENTRY_SOURCE_FALLBACK", value);
|
||||
expect(loadSecretContract()).toBe(42);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -134,7 +134,9 @@ const resolvedModulePaths = new Map<string, string>();
|
||||
const loadedModuleExports = new Map<string, unknown>();
|
||||
const disableBundledEntrySourceFallbackEnv = "OPENCLAW_DISABLE_BUNDLED_ENTRY_SOURCE_FALLBACK";
|
||||
|
||||
function isTruthyEnvFlag(value: string | undefined): boolean {
|
||||
function isBundledEntrySourceFallbackDisabled(value: string | undefined): boolean {
|
||||
// Presence-based disable is a shipped operator contract; canonical opt-in
|
||||
// truthiness intentionally does not apply to this packaging flag.
|
||||
return value !== undefined && !/^(?:0|false)$/iu.test(value.trim());
|
||||
}
|
||||
|
||||
@@ -235,7 +237,7 @@ function resolveBundledEntryModuleCandidates(
|
||||
if (!importerPath.startsWith(distExtensionsRoot)) {
|
||||
return candidates;
|
||||
}
|
||||
if (isTruthyEnvFlag(process.env[disableBundledEntrySourceFallbackEnv])) {
|
||||
if (isBundledEntrySourceFallbackDisabled(process.env[disableBundledEntrySourceFallbackEnv])) {
|
||||
return candidates;
|
||||
}
|
||||
|
||||
@@ -297,7 +299,9 @@ function formatBundledEntryModuleOpenFailure(params: {
|
||||
}
|
||||
|
||||
function createBundledEntryModulePathCacheKey(importMetaUrl: string, specifier: string): string {
|
||||
const sourceFallbackDisabled = isTruthyEnvFlag(process.env[disableBundledEntrySourceFallbackEnv]);
|
||||
const sourceFallbackDisabled = isBundledEntrySourceFallbackDisabled(
|
||||
process.env[disableBundledEntrySourceFallbackEnv],
|
||||
);
|
||||
return `${sourceFallbackDisabled ? "1" : "0"}\0${importMetaUrl}\0${specifier}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,17 @@ export {
|
||||
} from "../infra/diagnostic-events.js";
|
||||
export * from "../infra/diagnostic-flags.js";
|
||||
export * from "../infra/env.js";
|
||||
export * from "../infra/errors.js";
|
||||
export {
|
||||
collectErrorGraphCandidates,
|
||||
extractErrorCode,
|
||||
formatErrorMessage,
|
||||
formatUncaughtError,
|
||||
hasErrnoCode,
|
||||
isErrno,
|
||||
readErrorName,
|
||||
stringifyNonErrorCause,
|
||||
toErrorObject,
|
||||
} from "../infra/errors.js";
|
||||
import { extractErrorCode, formatErrorMessage } from "../infra/errors.js";
|
||||
|
||||
/** @deprecated Shipped compat only (removed from core in #104546); no core caller. Removal with the next plugin-SDK major. */
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { FileHandle } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { syncDirectoryIfSupported } from "../infra/directory-durability.js";
|
||||
import { isMissingPathError as isCanonicalMissingPathError } from "../infra/errors.js";
|
||||
import { sameFileIdentity, type FileIdentityStat } from "../infra/fs-safe-advanced.js";
|
||||
import { FsSafeError, root as createFsSafeRoot } from "../infra/fs-safe.js";
|
||||
|
||||
@@ -16,12 +17,7 @@ export type MemoryHostEventExportOwner = {
|
||||
type MemoryHostWorkspaceRoot = Awaited<ReturnType<typeof createFsSafeRoot>>;
|
||||
|
||||
export function isMissingPathError(error: unknown): boolean {
|
||||
const code = (error as { code?: unknown }).code;
|
||||
return (
|
||||
code === "ENOENT" ||
|
||||
code === "ENOTDIR" ||
|
||||
(error instanceof FsSafeError && code === "not-found")
|
||||
);
|
||||
return isCanonicalMissingPathError(error);
|
||||
}
|
||||
|
||||
export function isRejectedWorkspaceArtifactPath(error: unknown): boolean {
|
||||
|
||||
@@ -70,6 +70,8 @@ function hasUsableBundledPluginTree(pluginsDir: string): boolean {
|
||||
|
||||
function safeRealpathSync(targetPath: string): string | null {
|
||||
try {
|
||||
// Trusted-root containment requires native platform canonicalization here.
|
||||
// The shared plain-realpath helper must not replace this security boundary.
|
||||
return fs.realpathSync.native(targetPath);
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { resolveConfigEnvVars } from "../config/env-substitution.js";
|
||||
import { createConfigRuntimeEnv } from "../config/env-vars.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { PluginInstallRecord } from "../config/types.plugins.js";
|
||||
import { resolveRealpathOrAbsolute } from "../infra/boundary-path.js";
|
||||
import { tryReadJsonSync } from "../infra/json-files.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { resolvePluginActivationSourceConfig } from "./activation-source-config.js";
|
||||
@@ -31,14 +32,6 @@ import {
|
||||
import { normalizePluginIdScope, serializePluginIdScope } from "./plugin-scope.js";
|
||||
import type { PluginSdkResolutionPreference } from "./sdk-alias.js";
|
||||
|
||||
function safeRealpathOrResolve(value: string): string {
|
||||
try {
|
||||
return fs.realpathSync(value);
|
||||
} catch {
|
||||
return path.resolve(value);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveBundledPackageRootForCache(stockRoot?: string): string | undefined {
|
||||
if (!stockRoot) {
|
||||
return undefined;
|
||||
@@ -120,8 +113,8 @@ function resolveBundledPackageCacheIdentity(
|
||||
try {
|
||||
const stat = fs.statSync(packageJsonPath);
|
||||
identity = {
|
||||
packageJson: safeRealpathOrResolve(packageJsonPath),
|
||||
packageRoot: safeRealpathOrResolve(packageRoot),
|
||||
packageJson: resolveRealpathOrAbsolute(packageJsonPath),
|
||||
packageRoot: resolveRealpathOrAbsolute(packageRoot),
|
||||
packageVersion: readPackageVersionForCache(packageJsonPath),
|
||||
size: stat.size,
|
||||
mtimeMs: stat.mtimeMs,
|
||||
@@ -129,7 +122,7 @@ function resolveBundledPackageCacheIdentity(
|
||||
} catch {
|
||||
identity = {
|
||||
packageJson: path.resolve(packageJsonPath),
|
||||
packageRoot: safeRealpathOrResolve(packageRoot),
|
||||
packageRoot: resolveRealpathOrAbsolute(packageRoot),
|
||||
packageVersion: "missing",
|
||||
size: -1,
|
||||
mtimeMs: -1,
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { err as resultError, ok, type Result } from "@openclaw/normalization-core/result";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
@@ -7,6 +5,7 @@ import {
|
||||
} from "@openclaw/normalization-core/string-coerce";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { activateContextEngineRegistrations } from "../context-engine/registry.js";
|
||||
import { resolveRealpathOrAbsolute } from "../infra/boundary-path.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import {
|
||||
DEFAULT_MEMORY_DREAMING_PLUGIN_ID,
|
||||
@@ -371,9 +370,5 @@ export function activatePluginRegistry(
|
||||
}
|
||||
|
||||
export function safeRealpathOrResolve(value: string): string {
|
||||
try {
|
||||
return fs.realpathSync(value);
|
||||
} catch {
|
||||
return path.resolve(value);
|
||||
}
|
||||
return resolveRealpathOrAbsolute(value);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import { normalizeOptionalString as normalizeTrimmedString } from "@openclaw/normalization-core/string-coerce";
|
||||
import { resolveRealpathOrAbsolute } from "../infra/boundary-path.js";
|
||||
import { resolveHomeRelativePath } from "../infra/home-dir.js";
|
||||
import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js";
|
||||
import { readRegularFileSync } from "../infra/regular-file.js";
|
||||
@@ -155,18 +156,10 @@ function listSourceCheckoutPluginDirs(startOrder: number): CandidateDir[] {
|
||||
return dirs;
|
||||
}
|
||||
|
||||
function resolveComparablePath(filePath: string): string {
|
||||
try {
|
||||
return fs.realpathSync(filePath);
|
||||
} catch {
|
||||
return path.resolve(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
function uniqueCandidateDirs(candidates: CandidateDir[]): CandidateDir[] {
|
||||
const byPath = new Map<string, CandidateDir>();
|
||||
for (const candidate of candidates) {
|
||||
const key = resolveComparablePath(candidate.pluginDir);
|
||||
const key = resolveRealpathOrAbsolute(candidate.pluginDir);
|
||||
const existing = byPath.get(key);
|
||||
if (!existing || candidate.rank < existing.rank || candidate.order < existing.order) {
|
||||
byPath.set(key, candidate);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/** Resolves the exact root and entry selected by the plugin runtime loader. */
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { resolveRealpathOrAbsolute } from "../infra/boundary-path.js";
|
||||
import type { OpenClawPackageManifest } from "./manifest.js";
|
||||
import type { PluginOrigin } from "./plugin-origin.types.js";
|
||||
import type { PluginRegistry } from "./registry-types.js";
|
||||
@@ -8,15 +9,6 @@ import { getActivePluginRegistry, requireActivePluginRegistry } from "./runtime.
|
||||
|
||||
type PluginRuntimeArtifactEntryKind = "runtime" | "setup";
|
||||
|
||||
// Pin one physical path per plugin id and logical entry within one installed registry.
|
||||
function safeRealpathOrResolve(value: string): string {
|
||||
try {
|
||||
return fs.realpathSync(value);
|
||||
} catch {
|
||||
return path.resolve(value);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearPluginRuntimeArtifactResolutionMemo(): void {
|
||||
getActivePluginRegistry()?.pluginRuntimeArtifacts.clear();
|
||||
}
|
||||
@@ -100,7 +92,7 @@ function resolvePackageLocalDistRuntimeArtifact(params: {
|
||||
)) {
|
||||
const artifactSource = path.join(artifactRoot, artifactRelativePath);
|
||||
if (fs.existsSync(artifactSource)) {
|
||||
return safeRealpathOrResolve(artifactSource);
|
||||
return resolveRealpathOrAbsolute(artifactSource);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -113,8 +105,8 @@ function resolvePreferredBuiltRuntimeArtifact(params: {
|
||||
preferBuiltPluginArtifacts: boolean;
|
||||
packageManifest?: OpenClawPackageManifest;
|
||||
}): { source: string; rootDir: string } {
|
||||
const rootDir = safeRealpathOrResolve(params.rootDir);
|
||||
const source = safeRealpathOrResolve(params.source);
|
||||
const rootDir = resolveRealpathOrAbsolute(params.rootDir);
|
||||
const source = resolveRealpathOrAbsolute(params.source);
|
||||
if (!params.preferBuiltPluginArtifacts) {
|
||||
return { source, rootDir };
|
||||
}
|
||||
@@ -155,8 +147,8 @@ function resolvePreferredBuiltRuntimeArtifact(params: {
|
||||
const artifactSource = path.join(artifactRoot, artifactRelativePath);
|
||||
if (fs.existsSync(artifactSource)) {
|
||||
return {
|
||||
source: safeRealpathOrResolve(artifactSource),
|
||||
rootDir: safeRealpathOrResolve(artifactRoot),
|
||||
source: resolveRealpathOrAbsolute(artifactSource),
|
||||
rootDir: resolveRealpathOrAbsolute(artifactRoot),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -174,8 +166,8 @@ export function resolvePluginRuntimeArtifact(params: {
|
||||
packageManifest?: OpenClawPackageManifest;
|
||||
registry?: PluginRegistry;
|
||||
}): { source: string; rootDir: string } {
|
||||
const rootDir = resolveCanonicalDistRuntimeSource(safeRealpathOrResolve(params.rootDir));
|
||||
const source = resolveCanonicalDistRuntimeSource(safeRealpathOrResolve(params.source));
|
||||
const rootDir = resolveCanonicalDistRuntimeSource(resolveRealpathOrAbsolute(params.rootDir));
|
||||
const source = resolveCanonicalDistRuntimeSource(resolveRealpathOrAbsolute(params.source));
|
||||
const memoKey = JSON.stringify([params.pluginId, rootDir, params.entryKind]);
|
||||
const targetRegistry = params.registry ?? requireActivePluginRegistry();
|
||||
const cached = targetRegistry.pluginRuntimeArtifacts.get(memoKey);
|
||||
|
||||
@@ -3,6 +3,7 @@ import fs from "node:fs";
|
||||
import Module from "node:module";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { resolveRealpathOrAbsolute } from "../infra/boundary-path.js";
|
||||
import { PluginLruCache } from "./plugin-cache-primitives.js";
|
||||
import { registerPluginMetadataProcessMemoLifecycleClear } from "./plugin-metadata-lifecycle.js";
|
||||
import {
|
||||
@@ -152,13 +153,7 @@ function isNativeLoadableSdkTarget(targetPath: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePathForBoundary(candidate: string): string {
|
||||
try {
|
||||
return fs.realpathSync(candidate);
|
||||
} catch {
|
||||
return path.resolve(candidate);
|
||||
}
|
||||
}
|
||||
const normalizePathForBoundary = resolveRealpathOrAbsolute;
|
||||
|
||||
function findNearestPackageRoot(modulePath: string): string {
|
||||
let cursor = path.dirname(path.resolve(modulePath));
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
type SecretInput,
|
||||
type SecretRef,
|
||||
} from "../config/types.secrets.js";
|
||||
import { safeRealpathSync } from "../infra/boundary-path.js";
|
||||
import type { OAuthCredentials } from "../llm/oauth.js";
|
||||
import { getProviderEnvVars } from "../secrets/provider-env-vars.js";
|
||||
import { isValidSecretRef } from "../secrets/ref-contract.js";
|
||||
@@ -284,15 +285,6 @@ export function removeAuthProfileConfig(cfg: OpenClawConfig, profileId: string):
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolve real path, returning null if the target doesn't exist. */
|
||||
function safeRealpathSync(dir: string): string | null {
|
||||
try {
|
||||
return fs.realpathSync(path.resolve(dir));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSiblingAgentDirs(primaryAgentDir: string): string[] {
|
||||
const normalized = path.resolve(primaryAgentDir);
|
||||
const parentOfAgent = path.dirname(normalized);
|
||||
@@ -318,7 +310,7 @@ function resolveSiblingAgentDirs(primaryAgentDir: string): string[] {
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
for (const dir of [normalized, ...discovered]) {
|
||||
const real = safeRealpathSync(dir);
|
||||
const real = safeRealpathSync(path.resolve(dir));
|
||||
if (real && !seen.has(real)) {
|
||||
seen.add(real);
|
||||
result.push(real);
|
||||
@@ -358,9 +350,9 @@ export async function writeOAuthCredentials(
|
||||
});
|
||||
|
||||
if (options?.syncSiblingAgents) {
|
||||
const primaryReal = safeRealpathSync(resolvedAgentDir);
|
||||
const primaryReal = safeRealpathSync(path.resolve(resolvedAgentDir));
|
||||
for (const targetAgentDir of targetAgentDirs) {
|
||||
const targetReal = safeRealpathSync(targetAgentDir);
|
||||
const targetReal = safeRealpathSync(path.resolve(targetAgentDir));
|
||||
if (targetReal && primaryReal && targetReal === primaryReal) {
|
||||
continue;
|
||||
}
|
||||
|
||||
34
src/plugins/runtime-degraded-state.test.ts
Normal file
34
src/plugins/runtime-degraded-state.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { withTempDirSync } from "../test-helpers/temp-dir.js";
|
||||
import { pluginInstallPathMatchesRoot } from "./runtime-degraded-state.js";
|
||||
|
||||
describe("pluginInstallPathMatchesRoot", () => {
|
||||
it("matches an existing plugin root through a symlink alias", () => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
|
||||
withTempDirSync({ prefix: "openclaw-degraded-plugin-root-" }, (baseDir) => {
|
||||
const pluginRoot = path.join(baseDir, "plugin");
|
||||
const pluginAlias = path.join(baseDir, "plugin-alias");
|
||||
fs.mkdirSync(pluginRoot);
|
||||
fs.symlinkSync(pluginRoot, pluginAlias, "dir");
|
||||
|
||||
expect(pluginInstallPathMatchesRoot(pluginAlias, pluginRoot)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to absolute lexical paths when plugin roots are missing", () => {
|
||||
withTempDirSync({ prefix: "openclaw-degraded-plugin-root-" }, (baseDir) => {
|
||||
const missingRoot = path.join(baseDir, "missing-plugin");
|
||||
const equivalentMissingRoot = path.join(baseDir, "nested", "..", "missing-plugin");
|
||||
|
||||
expect(pluginInstallPathMatchesRoot(equivalentMissingRoot, missingRoot)).toBe(true);
|
||||
expect(pluginInstallPathMatchesRoot(path.join(baseDir, "other-missing"), missingRoot)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { resolveRealpathOrAbsolute } from "../infra/boundary-path.js";
|
||||
|
||||
/** Boot-stable quarantine state for configured plugins whose payload failed verification. */
|
||||
|
||||
@@ -101,14 +100,7 @@ export function pluginInstallPathMatchesRoot(
|
||||
if (!installPath) {
|
||||
return false;
|
||||
}
|
||||
const canonicalize = (value: string) => {
|
||||
try {
|
||||
return fs.realpathSync(value);
|
||||
} catch {
|
||||
return path.resolve(value);
|
||||
}
|
||||
};
|
||||
return canonicalize(installPath) === canonicalize(rootDir);
|
||||
return resolveRealpathOrAbsolute(installPath) === resolveRealpathOrAbsolute(rootDir);
|
||||
}
|
||||
|
||||
/** Matches install-record and discovered roots across symlink/path aliases. */
|
||||
|
||||
@@ -3,6 +3,7 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { isAcpRuntimeSpawnAvailable } from "../../acp/runtime/availability.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { isMissingPathError } from "../../infra/errors.js";
|
||||
import { walkDirectorySync } from "../../infra/fs-safe.js";
|
||||
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||
import {
|
||||
@@ -252,7 +253,7 @@ function publishPluginSkills(skillDirs: string[], opts?: { pluginSkillsDir?: str
|
||||
continue;
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isNotFoundError(err)) {
|
||||
if (!isMissingPathError(err)) {
|
||||
log.warn(`failed to inspect plugin skill symlink "${linkPath}": ${String(err)}`);
|
||||
continue;
|
||||
}
|
||||
@@ -300,7 +301,7 @@ function removeGeneratedPluginSkillEntry(linkPath: string): void {
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
if (isNotFoundError(err)) {
|
||||
if (isMissingPathError(err)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -310,11 +311,3 @@ function removeGeneratedPluginSkillEntry(linkPath: string): void {
|
||||
// best-effort cleanup
|
||||
}
|
||||
}
|
||||
|
||||
function isNotFoundError(err: unknown): boolean {
|
||||
if (!err || typeof err !== "object") {
|
||||
return false;
|
||||
}
|
||||
const code = (err as Record<string, unknown>).code;
|
||||
return code === "ENOENT" || code === "ENOTDIR";
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// Shared helpers for config-trusted skill symlink targets.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { safeRealpathSync } from "../../infra/boundary-path.js";
|
||||
import { isPathInside } from "../../infra/path-guards.js";
|
||||
import { resolveUserPath } from "../../utils.js";
|
||||
|
||||
@@ -12,7 +12,7 @@ export function resolveAllowedSkillSymlinkTargetRealPaths(config?: OpenClawConfi
|
||||
const targetPaths = rawTargets
|
||||
.map((dir) => normalizeOptionalString(dir) ?? "")
|
||||
.filter(Boolean)
|
||||
.map((dir) => tryRealpath(resolveUserPath(dir)))
|
||||
.map((dir) => safeRealpathSync(resolveUserPath(dir)))
|
||||
.filter((dir): dir is string => Boolean(dir));
|
||||
return uniqueStrings(targetPaths);
|
||||
}
|
||||
@@ -31,10 +31,4 @@ export function findContainingAllowedSkillSymlinkTarget(
|
||||
return null;
|
||||
}
|
||||
|
||||
export function tryRealpath(filePath: string): string | null {
|
||||
try {
|
||||
return fs.realpathSync(filePath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
export const tryRealpath = safeRealpathSync;
|
||||
|
||||
Reference in New Issue
Block a user