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:
Peter Steinberger
2026-08-08 10:58:57 -07:00
committed by GitHub
parent a07d0e249d
commit eecbfcc960
64 changed files with 432 additions and 412 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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();

View File

@@ -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) {

View File

@@ -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,
),
};

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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" };
}

View File

@@ -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 "";

View File

@@ -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. */

View 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);
});
});

View File

@@ -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();
}

View File

@@ -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");
}

View File

@@ -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);
});
});

View File

@@ -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);
}

View File

@@ -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 () => {});

View File

@@ -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 {

View File

@@ -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;
}
}

View File

@@ -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 });
}
}
};

View File

@@ -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 });
});
}

View File

@@ -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()

View File

@@ -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)}`);
}
},

View File

@@ -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,

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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,

View File

@@ -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("'", "'\\''")}'`;
}

View File

@@ -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("'", "'\\''")}'`;
}

View File

@@ -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])
);
}

View File

@@ -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" },

View File

@@ -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 {

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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.

View File

@@ -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
View 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")
);
}

View File

@@ -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" },

View File

@@ -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 });
}

View File

@@ -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);

View File

@@ -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(

View File

@@ -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 {

View File

@@ -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);

View File

@@ -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";
}

View File

@@ -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;

View File

@@ -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;

View 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);
});
});

View File

@@ -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.

View File

@@ -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);
}
});
});

View File

@@ -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}`;
}

View File

@@ -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. */

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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,

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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));

View File

@@ -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;
}

View 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,
);
});
});
});

View File

@@ -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. */

View File

@@ -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";
}

View File

@@ -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;