mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08:50:43 +00:00
refactor: share fs-safe JSON helpers
This commit is contained in:
committed by
GitHub
parent
cf83c5827d
commit
8f3a34e2a1
@@ -1695,7 +1695,7 @@
|
||||
"@mariozechner/pi-tui": "0.73.0",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"@openclaw/fs-safe": "^0.1.1",
|
||||
"@openclaw/fs-safe": "github:openclaw/fs-safe#3c508734af0b",
|
||||
"@slack/bolt": "^4.7.2",
|
||||
"@slack/types": "^2.21.0",
|
||||
"@slack/web-api": "^7.15.2",
|
||||
@@ -1801,6 +1801,7 @@
|
||||
"uuid": "14.0.0"
|
||||
},
|
||||
"onlyBuiltDependencies": [
|
||||
"@openclaw/fs-safe",
|
||||
"@discordjs/opus",
|
||||
"@google/genai",
|
||||
"@lydell/node-pty",
|
||||
|
||||
11
pnpm-lock.yaml
generated
11
pnpm-lock.yaml
generated
@@ -104,8 +104,8 @@ importers:
|
||||
specifier: ^0.6.0
|
||||
version: 0.6.0
|
||||
'@openclaw/fs-safe':
|
||||
specifier: ^0.1.1
|
||||
version: 0.1.1
|
||||
specifier: github:openclaw/fs-safe#3c508734af0b
|
||||
version: https://codeload.github.com/openclaw/fs-safe/tar.gz/3c508734af0baaad4f61cf70619bea98c783043b
|
||||
'@slack/bolt':
|
||||
specifier: ^4.7.2
|
||||
version: 4.7.2(@types/express@5.0.6)
|
||||
@@ -3233,8 +3233,9 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@openclaw/fs-safe@0.1.1':
|
||||
resolution: {integrity: sha512-+50LBpW7nKWzu3wJb4C5X9BQAcAxmj3oNRd/ZqZK+YO1ZdiikOPZ6fy5ta631xsV13+m+Ap5s0Gb3zPSl+0kOQ==}
|
||||
'@openclaw/fs-safe@https://codeload.github.com/openclaw/fs-safe/tar.gz/3c508734af0baaad4f61cf70619bea98c783043b':
|
||||
resolution: {tarball: https://codeload.github.com/openclaw/fs-safe/tar.gz/3c508734af0baaad4f61cf70619bea98c783043b}
|
||||
version: 0.1.2
|
||||
engines: {node: '>=20.11'}
|
||||
|
||||
'@opentelemetry/api-logs@0.216.0':
|
||||
@@ -10005,7 +10006,7 @@ snapshots:
|
||||
'@openai/codex@0.128.0-win32-x64':
|
||||
optional: true
|
||||
|
||||
'@openclaw/fs-safe@0.1.1':
|
||||
'@openclaw/fs-safe@https://codeload.github.com/openclaw/fs-safe/tar.gz/3c508734af0baaad4f61cf70619bea98c783043b':
|
||||
optionalDependencies:
|
||||
jszip: 3.10.1
|
||||
tar: 7.5.13
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { SettingsManager } from "@mariozechner/pi-coding-agent";
|
||||
import { applyMergePatch } from "../config/merge-patch.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { openRootFileSync } from "../infra/boundary-file-read.js";
|
||||
import { readRootJsonObjectSync } from "../infra/json-files.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import type { BundleMcpServerConfig } from "../plugins/bundle-mcp.js";
|
||||
import {
|
||||
@@ -11,7 +10,6 @@ import {
|
||||
resolveEffectivePluginActivationState,
|
||||
} from "../plugins/config-policy.js";
|
||||
import { loadPluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js";
|
||||
import { isRecord } from "../utils.js";
|
||||
import { loadEmbeddedPiMcpConfig } from "./embedded-pi-mcp.js";
|
||||
|
||||
const log = createSubsystemLogger("embedded-pi-settings");
|
||||
@@ -43,29 +41,21 @@ function loadBundleSettingsFile(params: {
|
||||
relativePath: string;
|
||||
}): PiSettingsSnapshot | null {
|
||||
const absolutePath = path.join(params.rootDir, params.relativePath);
|
||||
const opened = openRootFileSync({
|
||||
absolutePath,
|
||||
rootPath: params.rootDir,
|
||||
const result = readRootJsonObjectSync({
|
||||
rootDir: params.rootDir,
|
||||
relativePath: params.relativePath,
|
||||
boundaryLabel: "plugin root",
|
||||
rejectHardlinks: true,
|
||||
});
|
||||
if (!opened.ok) {
|
||||
if (!result.ok && result.reason === "open") {
|
||||
log.warn(`skipping unsafe bundle settings file: ${absolutePath}`);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(opened.fd, "utf-8")) as unknown;
|
||||
if (!isRecord(raw)) {
|
||||
log.warn(`skipping bundle settings file with non-object JSON: ${absolutePath}`);
|
||||
return null;
|
||||
}
|
||||
return sanitizePiSettingsSnapshot(raw as PiSettingsSnapshot);
|
||||
} catch (error) {
|
||||
log.warn(`failed to parse bundle settings file ${absolutePath}: ${String(error)}`);
|
||||
if (!result.ok) {
|
||||
log.warn(`${result.error}: ${absolutePath}`);
|
||||
return null;
|
||||
} finally {
|
||||
fs.closeSync(opened.fd);
|
||||
}
|
||||
return sanitizePiSettingsSnapshot(result.value as PiSettingsSnapshot);
|
||||
}
|
||||
|
||||
export function loadEnabledBundlePiSettingsSnapshot(params: {
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { readPackageManagerSpec } from "./package-json.js";
|
||||
|
||||
type DetectedPackageManager = "pnpm" | "bun" | "npm";
|
||||
|
||||
export async function detectPackageManager(root: string): Promise<DetectedPackageManager | null> {
|
||||
try {
|
||||
const raw = await fs.readFile(path.join(root, "package.json"), "utf-8");
|
||||
const parsed = JSON.parse(raw) as { packageManager?: string };
|
||||
const pm = parsed?.packageManager?.split("@")[0]?.trim();
|
||||
if (pm === "pnpm" || pm === "bun" || pm === "npm") {
|
||||
return pm;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
const pm = (await readPackageManagerSpec(root))?.split("@")[0]?.trim();
|
||||
if (pm === "pnpm" || pm === "bun" || pm === "npm") {
|
||||
return pm;
|
||||
}
|
||||
|
||||
const files = await fs.readdir(root).catch((): string[] => []);
|
||||
|
||||
@@ -6,6 +6,9 @@ export {
|
||||
readJsonIfExists,
|
||||
readJsonIfExists as readDurableJsonFile,
|
||||
readJsonSync,
|
||||
readRootJsonObjectSync,
|
||||
readRootJsonSync,
|
||||
readRootStructuredFileSync,
|
||||
tryReadJson,
|
||||
tryReadJson as readJsonFile,
|
||||
tryReadJsonSync,
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
ackJsonDurableQueueEntry,
|
||||
ensureJsonDurableQueueDirs,
|
||||
loadJsonDurableQueueEntry,
|
||||
loadPendingJsonDurableQueueEntries,
|
||||
moveJsonDurableQueueEntryToFailed,
|
||||
readJsonDurableQueueEntry,
|
||||
resolveJsonDurableQueueEntryPaths,
|
||||
writeJsonDurableQueueEntry,
|
||||
} from "@openclaw/fs-safe/store";
|
||||
import type { ReplyPayload } from "../../auto-reply/types.js";
|
||||
import type { RenderedMessageBatchPlanItem } from "../../channels/message/types.js";
|
||||
import { resolveStateDir } from "../../config/paths.js";
|
||||
import type { ReplyToMode } from "../../config/types.js";
|
||||
import { replaceFileAtomic } from "../replace-file.js";
|
||||
import { generateSecureUuid } from "../secure-random.js";
|
||||
import type { OutboundDeliveryFormattingOptions } from "./formatting.js";
|
||||
import type { OutboundIdentity } from "./identity.js";
|
||||
@@ -14,6 +22,7 @@ import type { OutboundChannel } from "./targets.js";
|
||||
|
||||
const QUEUE_DIRNAME = "delivery-queue";
|
||||
const FAILED_DIRNAME = "failed";
|
||||
const QUEUE_TEMP_PREFIX = ".delivery-queue";
|
||||
|
||||
export type QueuedRenderedMessageBatchPlan = {
|
||||
payloadCount: number;
|
||||
@@ -80,38 +89,19 @@ function resolveQueueEntryPaths(
|
||||
jsonPath: string;
|
||||
deliveredPath: string;
|
||||
} {
|
||||
const queueDir = resolveQueueDir(stateDir);
|
||||
return {
|
||||
jsonPath: path.join(queueDir, `${id}.json`),
|
||||
deliveredPath: path.join(queueDir, `${id}.delivered`),
|
||||
};
|
||||
}
|
||||
|
||||
function getErrnoCode(err: unknown): string | null {
|
||||
return err && typeof err === "object" && "code" in err
|
||||
? String((err as { code?: unknown }).code)
|
||||
: null;
|
||||
}
|
||||
|
||||
async function unlinkBestEffort(filePath: string): Promise<void> {
|
||||
try {
|
||||
await fs.promises.unlink(filePath);
|
||||
} catch {
|
||||
// Best-effort cleanup.
|
||||
}
|
||||
return resolveJsonDurableQueueEntryPaths(resolveQueueDir(stateDir), id);
|
||||
}
|
||||
|
||||
async function writeQueueEntry(filePath: string, entry: QueuedDelivery): Promise<void> {
|
||||
await replaceFileAtomic({
|
||||
await writeJsonDurableQueueEntry({
|
||||
filePath,
|
||||
content: JSON.stringify(entry, null, 2),
|
||||
mode: 0o600,
|
||||
tempPrefix: ".delivery-queue",
|
||||
entry,
|
||||
tempPrefix: QUEUE_TEMP_PREFIX,
|
||||
});
|
||||
}
|
||||
|
||||
async function readQueueEntry(filePath: string): Promise<QueuedDelivery> {
|
||||
return JSON.parse(await fs.promises.readFile(filePath, "utf-8")) as QueuedDelivery;
|
||||
return await readJsonDurableQueueEntry<QueuedDelivery>(filePath);
|
||||
}
|
||||
|
||||
function normalizeLegacyQueuedDeliveryEntry(entry: QueuedDelivery): {
|
||||
@@ -144,8 +134,10 @@ function normalizeLegacyQueuedDeliveryEntry(entry: QueuedDelivery): {
|
||||
/** Ensure the queue directory (and failed/ subdirectory) exist. */
|
||||
export async function ensureQueueDir(stateDir?: string): Promise<string> {
|
||||
const queueDir = resolveQueueDir(stateDir);
|
||||
await fs.promises.mkdir(queueDir, { recursive: true, mode: 0o700 });
|
||||
await fs.promises.mkdir(resolveFailedDir(stateDir), { recursive: true, mode: 0o700 });
|
||||
await ensureJsonDurableQueueDirs({
|
||||
queueDir,
|
||||
failedDir: resolveFailedDir(stateDir),
|
||||
});
|
||||
return queueDir;
|
||||
}
|
||||
|
||||
@@ -191,22 +183,7 @@ export async function enqueueDelivery(
|
||||
* by {@link loadPendingDeliveries} on the next startup without re-sending.
|
||||
*/
|
||||
export async function ackDelivery(id: string, stateDir?: string): Promise<void> {
|
||||
const { jsonPath, deliveredPath } = resolveQueueEntryPaths(id, stateDir);
|
||||
try {
|
||||
// Phase 1: atomic rename marks the delivery as complete.
|
||||
await fs.promises.rename(jsonPath, deliveredPath);
|
||||
} catch (err) {
|
||||
const code = getErrnoCode(err);
|
||||
if (code === "ENOENT") {
|
||||
// .json already gone — may have been renamed by a previous ack attempt.
|
||||
// Try to clean up a leftover .delivered marker if present.
|
||||
await unlinkBestEffort(deliveredPath);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
// Phase 2: remove the marker file.
|
||||
await unlinkBestEffort(deliveredPath);
|
||||
await ackJsonDurableQueueEntry(resolveQueueEntryPaths(id, stateDir));
|
||||
}
|
||||
|
||||
/** Update a queue entry after a failed delivery attempt. */
|
||||
@@ -246,76 +223,28 @@ export async function loadPendingDelivery(
|
||||
id: string,
|
||||
stateDir?: string,
|
||||
): Promise<QueuedDelivery | null> {
|
||||
const { jsonPath } = resolveQueueEntryPaths(id, stateDir);
|
||||
try {
|
||||
const stat = await fs.promises.stat(jsonPath);
|
||||
if (!stat.isFile()) {
|
||||
return null;
|
||||
}
|
||||
const { entry, migrated } = normalizeLegacyQueuedDeliveryEntry(await readQueueEntry(jsonPath));
|
||||
if (migrated) {
|
||||
await writeQueueEntry(jsonPath, entry);
|
||||
}
|
||||
return entry;
|
||||
} catch (err) {
|
||||
if (getErrnoCode(err) === "ENOENT") {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
return await loadJsonDurableQueueEntry({
|
||||
paths: resolveQueueEntryPaths(id, stateDir),
|
||||
tempPrefix: QUEUE_TEMP_PREFIX,
|
||||
read: async (entry) => normalizeLegacyQueuedDeliveryEntry(entry),
|
||||
});
|
||||
}
|
||||
|
||||
/** Load all pending delivery entries from the queue directory. */
|
||||
export async function loadPendingDeliveries(stateDir?: string): Promise<QueuedDelivery[]> {
|
||||
const queueDir = resolveQueueDir(stateDir);
|
||||
let files: string[];
|
||||
try {
|
||||
files = await fs.promises.readdir(queueDir);
|
||||
} catch (err) {
|
||||
const code = getErrnoCode(err);
|
||||
if (code === "ENOENT") {
|
||||
return [];
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Clean up .delivered markers left by ackDelivery if the process crashed
|
||||
// between the rename and the unlink.
|
||||
for (const file of files) {
|
||||
if (file.endsWith(".delivered")) {
|
||||
await unlinkBestEffort(path.join(queueDir, file));
|
||||
}
|
||||
}
|
||||
|
||||
const entries: QueuedDelivery[] = [];
|
||||
for (const file of files) {
|
||||
if (!file.endsWith(".json")) {
|
||||
continue;
|
||||
}
|
||||
const filePath = path.join(queueDir, file);
|
||||
try {
|
||||
const stat = await fs.promises.stat(filePath);
|
||||
if (!stat.isFile()) {
|
||||
continue;
|
||||
}
|
||||
const { entry, migrated } = normalizeLegacyQueuedDeliveryEntry(
|
||||
await readQueueEntry(filePath),
|
||||
);
|
||||
if (migrated) {
|
||||
await writeQueueEntry(filePath, entry);
|
||||
}
|
||||
entries.push(entry);
|
||||
} catch {
|
||||
// Skip malformed or inaccessible entries.
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
return await loadPendingJsonDurableQueueEntries({
|
||||
queueDir,
|
||||
tempPrefix: QUEUE_TEMP_PREFIX,
|
||||
read: async (entry) => normalizeLegacyQueuedDeliveryEntry(entry),
|
||||
});
|
||||
}
|
||||
|
||||
/** Move a queue entry to the failed/ subdirectory. */
|
||||
export async function moveToFailed(id: string, stateDir?: string): Promise<void> {
|
||||
const queueDir = resolveQueueDir(stateDir);
|
||||
const failedDir = resolveFailedDir(stateDir);
|
||||
await fs.promises.mkdir(failedDir, { recursive: true, mode: 0o700 });
|
||||
await fs.promises.rename(path.join(queueDir, `${id}.json`), path.join(failedDir, `${id}.json`));
|
||||
await moveJsonDurableQueueEntryToFailed({
|
||||
queueDir: resolveQueueDir(stateDir),
|
||||
failedDir: resolveFailedDir(stateDir),
|
||||
id,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { withTempDir } from "../test-helpers/temp-dir.js";
|
||||
import { readPackageName, readPackageVersion } from "./package-json.js";
|
||||
import { readPackageManagerSpec, readPackageName, readPackageVersion } from "./package-json.js";
|
||||
|
||||
async function expectPackageMeta(params: {
|
||||
root: string;
|
||||
@@ -18,7 +18,11 @@ describe("package-json helpers", () => {
|
||||
await withTempDir({ prefix: "openclaw-package-json-" }, async (root) => {
|
||||
await fs.writeFile(
|
||||
path.join(root, "package.json"),
|
||||
JSON.stringify({ version: " 1.2.3 ", name: " @openclaw/demo " }),
|
||||
JSON.stringify({
|
||||
version: " 1.2.3 ",
|
||||
name: " @openclaw/demo ",
|
||||
packageManager: " pnpm@10.8.1 ",
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
@@ -27,6 +31,7 @@ describe("package-json helpers", () => {
|
||||
expectedVersion: "1.2.3",
|
||||
expectedName: "@openclaw/demo",
|
||||
});
|
||||
await expect(readPackageManagerSpec(root)).resolves.toBe("pnpm@10.8.1");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,24 +1,31 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { tryReadJson } from "./json-files.js";
|
||||
|
||||
type PackageJson = {
|
||||
name?: unknown;
|
||||
packageManager?: unknown;
|
||||
version?: unknown;
|
||||
};
|
||||
|
||||
function normalizeString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
export async function readPackageJson(root: string): Promise<PackageJson | null> {
|
||||
const parsed = await tryReadJson<unknown>(path.join(root, "package.json"));
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? (parsed as PackageJson)
|
||||
: null;
|
||||
}
|
||||
|
||||
export async function readPackageVersion(root: string): Promise<string | null> {
|
||||
try {
|
||||
const raw = await fs.readFile(path.join(root, "package.json"), "utf-8");
|
||||
const parsed = JSON.parse(raw) as { version?: string };
|
||||
const version = parsed?.version?.trim();
|
||||
return version ? version : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return normalizeString((await readPackageJson(root))?.version);
|
||||
}
|
||||
|
||||
export async function readPackageName(root: string): Promise<string | null> {
|
||||
try {
|
||||
const raw = await fs.readFile(path.join(root, "package.json"), "utf-8");
|
||||
const parsed = JSON.parse(raw) as { name?: string };
|
||||
const name = parsed?.name?.trim();
|
||||
return name ? name : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return normalizeString((await readPackageJson(root))?.name);
|
||||
}
|
||||
|
||||
export async function readPackageManagerSpec(root: string): Promise<string | null> {
|
||||
return normalizeString((await readPackageJson(root))?.packageManager);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import fsSync from "node:fs";
|
||||
import path from "node:path";
|
||||
import { openRootFileSync } from "./boundary-file-read.js";
|
||||
import { readRootJsonObjectSync } from "@openclaw/fs-safe/json";
|
||||
|
||||
export function expectedIntegrityForUpdate(
|
||||
spec: string | undefined,
|
||||
@@ -29,23 +29,12 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
}
|
||||
|
||||
function readInstalledPackageManifest(dir: string): Record<string, unknown> | undefined {
|
||||
const manifestPath = path.join(dir, "package.json");
|
||||
const opened = openRootFileSync({
|
||||
absolutePath: manifestPath,
|
||||
rootPath: dir,
|
||||
const result = readRootJsonObjectSync({
|
||||
rootDir: dir,
|
||||
relativePath: "package.json",
|
||||
boundaryLabel: "installed package directory",
|
||||
});
|
||||
if (!opened.ok) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(fsSync.readFileSync(opened.fd, "utf-8")) as unknown;
|
||||
return isRecord(parsed) ? parsed : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
} finally {
|
||||
fsSync.closeSync(opened.fd);
|
||||
}
|
||||
return result.ok ? result.value : undefined;
|
||||
}
|
||||
|
||||
export async function readInstalledPackageVersion(dir: string): Promise<string | undefined> {
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
ackJsonDurableQueueEntry,
|
||||
ensureJsonDurableQueueDirs,
|
||||
jsonDurableQueueEntryExists,
|
||||
loadJsonDurableQueueEntry,
|
||||
loadPendingJsonDurableQueueEntries,
|
||||
moveJsonDurableQueueEntryToFailed,
|
||||
readJsonDurableQueueEntry,
|
||||
resolveJsonDurableQueueEntryPaths,
|
||||
writeJsonDurableQueueEntry,
|
||||
} from "@openclaw/fs-safe/store";
|
||||
import type { ChatType } from "../channels/chat-type.js";
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import { replaceFileAtomic } from "./replace-file.js";
|
||||
import { generateSecureUuid } from "./secure-random.js";
|
||||
|
||||
const QUEUE_DIRNAME = "session-delivery-queue";
|
||||
const FAILED_DIRNAME = "failed";
|
||||
const TMP_SWEEP_MAX_AGE_MS = 5_000;
|
||||
const QUEUE_TEMP_PREFIX = ".session-delivery-queue";
|
||||
|
||||
type SessionDeliveryContext = {
|
||||
channel?: string;
|
||||
@@ -52,12 +62,6 @@ export type QueuedSessionDelivery = QueuedSessionDeliveryPayload & {
|
||||
lastError?: string;
|
||||
};
|
||||
|
||||
function getErrnoCode(err: unknown): string | null {
|
||||
return err && typeof err === "object" && "code" in err
|
||||
? String((err as { code?: unknown }).code)
|
||||
: null;
|
||||
}
|
||||
|
||||
function buildEntryId(idempotencyKey?: string): string {
|
||||
if (!idempotencyKey) {
|
||||
return generateSecureUuid();
|
||||
@@ -65,38 +69,16 @@ function buildEntryId(idempotencyKey?: string): string {
|
||||
return createHash("sha256").update(idempotencyKey).digest("hex");
|
||||
}
|
||||
|
||||
async function unlinkBestEffort(filePath: string): Promise<void> {
|
||||
await fs.promises.unlink(filePath).catch(() => undefined);
|
||||
}
|
||||
|
||||
async function unlinkStaleTmpBestEffort(filePath: string, now: number): Promise<void> {
|
||||
try {
|
||||
const stat = await fs.promises.stat(filePath);
|
||||
if (!stat.isFile()) {
|
||||
return;
|
||||
}
|
||||
if (now - stat.mtimeMs < TMP_SWEEP_MAX_AGE_MS) {
|
||||
return;
|
||||
}
|
||||
await unlinkBestEffort(filePath);
|
||||
} catch (err) {
|
||||
if (getErrnoCode(err) !== "ENOENT") {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function writeQueueEntry(filePath: string, entry: QueuedSessionDelivery): Promise<void> {
|
||||
await replaceFileAtomic({
|
||||
await writeJsonDurableQueueEntry({
|
||||
filePath,
|
||||
content: JSON.stringify(entry, null, 2),
|
||||
mode: 0o600,
|
||||
tempPrefix: ".session-delivery-queue",
|
||||
entry,
|
||||
tempPrefix: QUEUE_TEMP_PREFIX,
|
||||
});
|
||||
}
|
||||
|
||||
async function readQueueEntry(filePath: string): Promise<QueuedSessionDelivery> {
|
||||
return JSON.parse(await fs.promises.readFile(filePath, "utf-8")) as QueuedSessionDelivery;
|
||||
return await readJsonDurableQueueEntry<QueuedSessionDelivery>(filePath);
|
||||
}
|
||||
|
||||
export function resolveSessionDeliveryQueueDir(stateDir?: string): string {
|
||||
@@ -115,17 +97,15 @@ function resolveQueueEntryPaths(
|
||||
jsonPath: string;
|
||||
deliveredPath: string;
|
||||
} {
|
||||
const queueDir = resolveSessionDeliveryQueueDir(stateDir);
|
||||
return {
|
||||
jsonPath: path.join(queueDir, `${id}.json`),
|
||||
deliveredPath: path.join(queueDir, `${id}.delivered`),
|
||||
};
|
||||
return resolveJsonDurableQueueEntryPaths(resolveSessionDeliveryQueueDir(stateDir), id);
|
||||
}
|
||||
|
||||
async function ensureSessionDeliveryQueueDir(stateDir?: string): Promise<string> {
|
||||
const queueDir = resolveSessionDeliveryQueueDir(stateDir);
|
||||
await fs.promises.mkdir(queueDir, { recursive: true, mode: 0o700 });
|
||||
await fs.promises.mkdir(resolveFailedDir(stateDir), { recursive: true, mode: 0o700 });
|
||||
await ensureJsonDurableQueueDirs({
|
||||
queueDir,
|
||||
failedDir: resolveFailedDir(stateDir),
|
||||
});
|
||||
return queueDir;
|
||||
}
|
||||
|
||||
@@ -138,15 +118,8 @@ export async function enqueueSessionDelivery(
|
||||
const filePath = path.join(queueDir, `${id}.json`);
|
||||
|
||||
if (params.idempotencyKey) {
|
||||
try {
|
||||
const stat = await fs.promises.stat(filePath);
|
||||
if (stat.isFile()) {
|
||||
return id;
|
||||
}
|
||||
} catch (err) {
|
||||
if (getErrnoCode(err) !== "ENOENT") {
|
||||
throw err;
|
||||
}
|
||||
if (await jsonDurableQueueEntryExists(filePath)) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,18 +133,7 @@ export async function enqueueSessionDelivery(
|
||||
}
|
||||
|
||||
export async function ackSessionDelivery(id: string, stateDir?: string): Promise<void> {
|
||||
const { jsonPath, deliveredPath } = resolveQueueEntryPaths(id, stateDir);
|
||||
try {
|
||||
await fs.promises.rename(jsonPath, deliveredPath);
|
||||
} catch (err) {
|
||||
const code = getErrnoCode(err);
|
||||
if (code === "ENOENT") {
|
||||
await unlinkBestEffort(deliveredPath);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
await unlinkBestEffort(deliveredPath);
|
||||
await ackJsonDurableQueueEntry(resolveQueueEntryPaths(id, stateDir));
|
||||
}
|
||||
|
||||
export async function failSessionDelivery(
|
||||
@@ -191,66 +153,26 @@ export async function loadPendingSessionDelivery(
|
||||
id: string,
|
||||
stateDir?: string,
|
||||
): Promise<QueuedSessionDelivery | null> {
|
||||
const { jsonPath } = resolveQueueEntryPaths(id, stateDir);
|
||||
try {
|
||||
const stat = await fs.promises.stat(jsonPath);
|
||||
if (!stat.isFile()) {
|
||||
return null;
|
||||
}
|
||||
return await readQueueEntry(jsonPath);
|
||||
} catch (err) {
|
||||
if (getErrnoCode(err) === "ENOENT") {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
return await loadJsonDurableQueueEntry({
|
||||
paths: resolveQueueEntryPaths(id, stateDir),
|
||||
tempPrefix: QUEUE_TEMP_PREFIX,
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadPendingSessionDeliveries(
|
||||
stateDir?: string,
|
||||
): Promise<QueuedSessionDelivery[]> {
|
||||
const queueDir = resolveSessionDeliveryQueueDir(stateDir);
|
||||
let files: string[];
|
||||
try {
|
||||
files = await fs.promises.readdir(queueDir);
|
||||
} catch (err) {
|
||||
if (getErrnoCode(err) === "ENOENT") {
|
||||
return [];
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
for (const file of files) {
|
||||
if (file.endsWith(".delivered")) {
|
||||
await unlinkBestEffort(path.join(queueDir, file));
|
||||
} else if (file.endsWith(".tmp")) {
|
||||
await unlinkStaleTmpBestEffort(path.join(queueDir, file), now);
|
||||
}
|
||||
}
|
||||
|
||||
const entries: QueuedSessionDelivery[] = [];
|
||||
for (const file of files) {
|
||||
if (!file.endsWith(".json")) {
|
||||
continue;
|
||||
}
|
||||
const filePath = path.join(queueDir, file);
|
||||
try {
|
||||
const stat = await fs.promises.stat(filePath);
|
||||
if (!stat.isFile()) {
|
||||
continue;
|
||||
}
|
||||
entries.push(await readQueueEntry(filePath));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
return await loadPendingJsonDurableQueueEntries({
|
||||
queueDir: resolveSessionDeliveryQueueDir(stateDir),
|
||||
tempPrefix: QUEUE_TEMP_PREFIX,
|
||||
cleanupTmpMaxAgeMs: TMP_SWEEP_MAX_AGE_MS,
|
||||
});
|
||||
}
|
||||
|
||||
export async function moveSessionDeliveryToFailed(id: string, stateDir?: string): Promise<void> {
|
||||
const queueDir = resolveSessionDeliveryQueueDir(stateDir);
|
||||
const failedDir = resolveFailedDir(stateDir);
|
||||
await fs.promises.mkdir(failedDir, { recursive: true, mode: 0o700 });
|
||||
await fs.promises.rename(path.join(queueDir, `${id}.json`), path.join(failedDir, `${id}.json`));
|
||||
await moveJsonDurableQueueEntryToFailed({
|
||||
queueDir: resolveSessionDeliveryQueueDir(stateDir),
|
||||
failedDir: resolveFailedDir(stateDir),
|
||||
id,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -152,14 +152,28 @@ export async function ensureMediaDir() {
|
||||
return mediaDir;
|
||||
}
|
||||
|
||||
function isMissingPathError(err: unknown): err is NodeJS.ErrnoException {
|
||||
return err instanceof Error && "code" in err && err.code === "ENOENT";
|
||||
function findErrorWithCode(err: unknown, code: string): NodeJS.ErrnoException | undefined {
|
||||
if (!(err instanceof Error)) {
|
||||
return undefined;
|
||||
}
|
||||
if ("code" in err && err.code === code) {
|
||||
return err as NodeJS.ErrnoException;
|
||||
}
|
||||
return findErrorWithCode(err.cause, code);
|
||||
}
|
||||
|
||||
function isMissingPathError(err: unknown): boolean {
|
||||
return findErrorWithCode(err, "ENOENT") !== undefined;
|
||||
}
|
||||
|
||||
async function retryAfterRecreatingDir<T>(dir: string, run: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await run();
|
||||
} catch (err) {
|
||||
const noSpaceError = findErrorWithCode(err, "ENOSPC");
|
||||
if (noSpaceError) {
|
||||
throw noSpaceError;
|
||||
}
|
||||
if (!isMissingPathError(err)) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { openRootFileSync } from "../infra/boundary-file-read.js";
|
||||
import { readRootJsonObjectSync } from "../infra/json-files.js";
|
||||
import { parseFrontmatterBlock } from "../markdown/frontmatter.js";
|
||||
import { isPathInsideWithRealpath } from "../security/scan-paths.js";
|
||||
import {
|
||||
@@ -55,26 +55,13 @@ function stripFrontmatter(content: string): string {
|
||||
}
|
||||
|
||||
function readClaudeBundleManifest(rootDir: string): Record<string, unknown> {
|
||||
const manifestPath = path.join(rootDir, CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH);
|
||||
const opened = openRootFileSync({
|
||||
absolutePath: manifestPath,
|
||||
rootPath: rootDir,
|
||||
const result = readRootJsonObjectSync({
|
||||
rootDir,
|
||||
relativePath: CLAUDE_BUNDLE_MANIFEST_RELATIVE_PATH,
|
||||
boundaryLabel: "plugin root",
|
||||
rejectHardlinks: true,
|
||||
});
|
||||
if (!opened.ok) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(opened.fd, "utf-8")) as unknown;
|
||||
return raw && typeof raw === "object" && !Array.isArray(raw)
|
||||
? (raw as Record<string, unknown>)
|
||||
: {};
|
||||
} catch {
|
||||
return {};
|
||||
} finally {
|
||||
fs.closeSync(opened.fd);
|
||||
}
|
||||
return result.ok ? result.value : {};
|
||||
}
|
||||
|
||||
function resolveClaudeCommandRootDirs(rootDir: string): string[] {
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { applyMergePatch } from "../config/merge-patch.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { matchRootFileOpenFailure, openRootFileSync } from "../infra/boundary-file-read.js";
|
||||
import { isRecord } from "../utils.js";
|
||||
import { matchRootFileOpenFailure, type RootFileOpenFailure } from "../infra/boundary-file-read.js";
|
||||
import { readRootJsonObjectSync } from "../infra/json-files.js";
|
||||
import { normalizePluginsConfig, resolveEffectivePluginActivationState } from "./config-state.js";
|
||||
import type { PluginBundleFormat } from "./manifest-types.js";
|
||||
import { loadPluginManifestRegistryForPluginRegistry } from "./plugin-registry.js";
|
||||
@@ -22,35 +20,25 @@ export type BundleServerRuntimeSupport = {
|
||||
export function readBundleJsonObject(params: {
|
||||
rootDir: string;
|
||||
relativePath: string;
|
||||
onOpenFailure?: (
|
||||
failure: Extract<ReturnType<typeof openRootFileSync>, { ok: false }>,
|
||||
) => ReadBundleJsonResult;
|
||||
onOpenFailure?: (failure: RootFileOpenFailure) => ReadBundleJsonResult;
|
||||
}): ReadBundleJsonResult {
|
||||
const absolutePath = path.join(params.rootDir, params.relativePath);
|
||||
const opened = openRootFileSync({
|
||||
absolutePath,
|
||||
rootPath: params.rootDir,
|
||||
const result = readRootJsonObjectSync({
|
||||
rootDir: params.rootDir,
|
||||
relativePath: params.relativePath,
|
||||
boundaryLabel: "plugin root",
|
||||
rejectHardlinks: true,
|
||||
});
|
||||
if (!opened.ok) {
|
||||
return params.onOpenFailure?.(opened) ?? { ok: true, raw: {} };
|
||||
if (result.ok) {
|
||||
return { ok: true, raw: result.value };
|
||||
}
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(opened.fd, "utf-8")) as unknown;
|
||||
if (!isRecord(raw)) {
|
||||
return { ok: false, error: `${params.relativePath} must contain a JSON object` };
|
||||
}
|
||||
return { ok: true, raw };
|
||||
} catch (error) {
|
||||
return { ok: false, error: `failed to parse ${params.relativePath}: ${String(error)}` };
|
||||
} finally {
|
||||
fs.closeSync(opened.fd);
|
||||
if (result.reason === "open") {
|
||||
return params.onOpenFailure?.(result.failure) ?? { ok: true, raw: {} };
|
||||
}
|
||||
return { ok: false, error: result.error };
|
||||
}
|
||||
|
||||
export function resolveBundleJsonOpenFailure(params: {
|
||||
failure: Extract<ReturnType<typeof openRootFileSync>, { ok: false }>;
|
||||
failure: RootFileOpenFailure;
|
||||
relativePath: string;
|
||||
allowMissing?: boolean;
|
||||
}): ReadBundleJsonResult {
|
||||
|
||||
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { applyMergePatch } from "../config/merge-patch.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { openRootFileSync } from "../infra/boundary-file-read.js";
|
||||
import { readRootJsonObjectSync } from "../infra/json-files.js";
|
||||
import { isRecord } from "../utils.js";
|
||||
import {
|
||||
inspectBundleServerRuntimeSupport,
|
||||
@@ -60,30 +60,32 @@ function resolveBundleLspConfigPaths(params: {
|
||||
return mergeBundlePathLists(defaults, declared);
|
||||
}
|
||||
|
||||
function loadBundleLspConfigFile(params: {
|
||||
rootDir: string;
|
||||
relativePath: string;
|
||||
}): BundleLspConfig {
|
||||
const absolutePath = path.resolve(params.rootDir, params.relativePath);
|
||||
const opened = openRootFileSync({
|
||||
absolutePath,
|
||||
rootPath: params.rootDir,
|
||||
function loadBundleLspConfigFile(params: { rootDir: string; relativePath: string }): {
|
||||
config: BundleLspConfig;
|
||||
diagnostics: string[];
|
||||
} {
|
||||
const result = readRootJsonObjectSync({
|
||||
rootDir: params.rootDir,
|
||||
relativePath: params.relativePath,
|
||||
boundaryLabel: "plugin root",
|
||||
rejectHardlinks: true,
|
||||
});
|
||||
if (!opened.ok) {
|
||||
return { lspServers: {} };
|
||||
}
|
||||
try {
|
||||
const stat = fs.fstatSync(opened.fd);
|
||||
if (!stat.isFile()) {
|
||||
return { lspServers: {} };
|
||||
if (!result.ok) {
|
||||
if (result.reason === "open") {
|
||||
return {
|
||||
config: { lspServers: {} },
|
||||
diagnostics:
|
||||
result.failure.reason === "path"
|
||||
? []
|
||||
: [`unable to read ${params.relativePath}: ${result.failure.reason}`],
|
||||
};
|
||||
}
|
||||
const raw = JSON.parse(fs.readFileSync(opened.fd, "utf-8")) as unknown;
|
||||
return { lspServers: extractLspServerMap(raw) };
|
||||
} finally {
|
||||
fs.closeSync(opened.fd);
|
||||
return {
|
||||
config: { lspServers: {} },
|
||||
diagnostics: [`unable to read ${params.relativePath}: ${result.error}`],
|
||||
};
|
||||
}
|
||||
return { config: { lspServers: extractLspServerMap(result.value) }, diagnostics: [] };
|
||||
}
|
||||
|
||||
function loadBundleLspConfig(params: {
|
||||
@@ -109,17 +111,17 @@ function loadBundleLspConfig(params: {
|
||||
raw: manifestLoaded.raw,
|
||||
rootDir: params.rootDir,
|
||||
});
|
||||
const diagnostics: string[] = [];
|
||||
for (const relativePath of filePaths) {
|
||||
merged = applyMergePatch(
|
||||
merged,
|
||||
loadBundleLspConfigFile({
|
||||
rootDir: params.rootDir,
|
||||
relativePath,
|
||||
}),
|
||||
) as BundleLspConfig;
|
||||
const loaded = loadBundleLspConfigFile({
|
||||
rootDir: params.rootDir,
|
||||
relativePath,
|
||||
});
|
||||
diagnostics.push(...loaded.diagnostics);
|
||||
merged = applyMergePatch(merged, loaded.config) as BundleLspConfig;
|
||||
}
|
||||
|
||||
return { config: merged, diagnostics: [] };
|
||||
return { config: merged, diagnostics };
|
||||
}
|
||||
|
||||
export function inspectBundleLspRuntimeSupport(params: {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import JSON5 from "json5";
|
||||
import { matchRootFileOpenFailure, openRootFileSync } from "../infra/boundary-file-read.js";
|
||||
import { matchRootFileOpenFailure } from "../infra/boundary-file-read.js";
|
||||
import { readRootStructuredFileSync } from "../infra/json-files.js";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
@@ -98,15 +99,17 @@ function loadBundleManifestFile(params: {
|
||||
allowMissing?: boolean;
|
||||
}): BundleManifestFileLoadResult {
|
||||
const manifestPath = path.join(params.rootDir, params.manifestRelativePath);
|
||||
const opened = openRootFileSync({
|
||||
absolutePath: manifestPath,
|
||||
rootPath: params.rootDir,
|
||||
const result = readRootStructuredFileSync<Record<string, unknown>>({
|
||||
rootDir: params.rootDir,
|
||||
...(params.rootRealPath !== undefined ? { rootRealPath: params.rootRealPath } : {}),
|
||||
relativePath: params.manifestRelativePath,
|
||||
boundaryLabel: "plugin root",
|
||||
rejectHardlinks: params.rejectHardlinks,
|
||||
parse: (raw) => JSON5.parse(raw),
|
||||
validate: isRecord,
|
||||
});
|
||||
if (!opened.ok) {
|
||||
return matchRootFileOpenFailure(opened, {
|
||||
if (!result.ok && result.reason === "open") {
|
||||
return matchRootFileOpenFailure(result.failure, {
|
||||
path: () => {
|
||||
if (params.allowMissing) {
|
||||
return { ok: true, raw: {}, manifestPath };
|
||||
@@ -120,21 +123,17 @@ function loadBundleManifestFile(params: {
|
||||
}),
|
||||
});
|
||||
}
|
||||
try {
|
||||
const raw = JSON5.parse(fs.readFileSync(opened.fd, "utf-8")) as unknown;
|
||||
if (!isRecord(raw)) {
|
||||
return { ok: false, error: "plugin manifest must be an object", manifestPath };
|
||||
}
|
||||
return { ok: true, raw, manifestPath };
|
||||
} catch (err) {
|
||||
if (!result.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `failed to parse plugin manifest: ${String(err)}`,
|
||||
error:
|
||||
result.reason === "invalid"
|
||||
? "plugin manifest must be an object"
|
||||
: `failed to parse plugin manifest: ${result.error}`,
|
||||
manifestPath,
|
||||
};
|
||||
} finally {
|
||||
fs.closeSync(opened.fd);
|
||||
}
|
||||
return { ok: true, raw: result.value, manifestPath };
|
||||
}
|
||||
|
||||
function resolveCodexSkillDirs(raw: Record<string, unknown>, rootDir: string): string[] {
|
||||
|
||||
@@ -3,6 +3,7 @@ import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { isRecord } from "../utils.js";
|
||||
import { loadEnabledBundleLspConfig } from "./bundle-lsp.js";
|
||||
import { loadEnabledBundleMcpConfig } from "./bundle-mcp.js";
|
||||
import {
|
||||
createEnabledPluginEntries,
|
||||
@@ -218,4 +219,66 @@ describe("loadEnabledBundleMcpConfig", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("reports malformed file-backed MCP configs instead of silently dropping servers", async () => {
|
||||
await withBundleHomeEnv(
|
||||
tempHarness,
|
||||
"openclaw-bundle-malformed-mcp",
|
||||
async ({ homeDir, workspaceDir }) => {
|
||||
const pluginRoot = await writeClaudeBundleManifest({
|
||||
homeDir,
|
||||
pluginId: "malformed-mcp",
|
||||
manifest: {
|
||||
name: "malformed-mcp",
|
||||
mcpServers: ".mcp.json",
|
||||
},
|
||||
});
|
||||
await fs.writeFile(path.join(pluginRoot, ".mcp.json"), "{", "utf-8");
|
||||
|
||||
const loaded = loadEnabledBundleMcpConfig({
|
||||
workspaceDir,
|
||||
cfg: createEnabledBundleConfig(["malformed-mcp"]),
|
||||
});
|
||||
|
||||
expect(loaded.config.mcpServers).toEqual({});
|
||||
expect(loaded.diagnostics).toEqual([
|
||||
expect.objectContaining({
|
||||
pluginId: "malformed-mcp",
|
||||
message: expect.stringContaining("unable to read .mcp.json"),
|
||||
}),
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("reports malformed file-backed LSP configs instead of silently dropping servers", async () => {
|
||||
await withBundleHomeEnv(
|
||||
tempHarness,
|
||||
"openclaw-bundle-malformed-lsp",
|
||||
async ({ homeDir, workspaceDir }) => {
|
||||
const pluginRoot = await writeClaudeBundleManifest({
|
||||
homeDir,
|
||||
pluginId: "malformed-lsp",
|
||||
manifest: {
|
||||
name: "malformed-lsp",
|
||||
lspServers: ".lsp.json",
|
||||
},
|
||||
});
|
||||
await fs.writeFile(path.join(pluginRoot, ".lsp.json"), "{", "utf-8");
|
||||
|
||||
const loaded = loadEnabledBundleLspConfig({
|
||||
workspaceDir,
|
||||
cfg: createEnabledBundleConfig(["malformed-lsp"]),
|
||||
});
|
||||
|
||||
expect(loaded.config.lspServers).toEqual({});
|
||||
expect(loaded.diagnostics).toEqual([
|
||||
expect.objectContaining({
|
||||
pluginId: "malformed-lsp",
|
||||
message: expect.stringContaining("unable to read .lsp.json"),
|
||||
}),
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { applyMergePatch } from "../config/merge-patch.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { openRootFileSync } from "../infra/boundary-file-read.js";
|
||||
import { readRootJsonObjectSync } from "../infra/json-files.js";
|
||||
import { isRecord } from "../utils.js";
|
||||
import {
|
||||
inspectBundleServerRuntimeSupport,
|
||||
@@ -162,40 +162,46 @@ function absolutizeBundleMcpServer(params: {
|
||||
return next;
|
||||
}
|
||||
|
||||
function loadBundleFileBackedMcpConfig(params: {
|
||||
rootDir: string;
|
||||
relativePath: string;
|
||||
}): BundleMcpConfig {
|
||||
function loadBundleFileBackedMcpConfig(params: { rootDir: string; relativePath: string }): {
|
||||
config: BundleMcpConfig;
|
||||
diagnostics: string[];
|
||||
} {
|
||||
const rootDir = normalizeBundlePath(params.rootDir);
|
||||
const absolutePath = path.resolve(rootDir, params.relativePath);
|
||||
const opened = openRootFileSync({
|
||||
absolutePath,
|
||||
rootPath: rootDir,
|
||||
const result = readRootJsonObjectSync({
|
||||
rootDir,
|
||||
relativePath: params.relativePath,
|
||||
boundaryLabel: "plugin root",
|
||||
rejectHardlinks: true,
|
||||
});
|
||||
if (!opened.ok) {
|
||||
return { mcpServers: {} };
|
||||
}
|
||||
try {
|
||||
const stat = fs.fstatSync(opened.fd);
|
||||
if (!stat.isFile()) {
|
||||
return { mcpServers: {} };
|
||||
if (!result.ok) {
|
||||
if (result.reason === "open") {
|
||||
return {
|
||||
config: { mcpServers: {} },
|
||||
diagnostics:
|
||||
result.failure.reason === "path"
|
||||
? []
|
||||
: [`unable to read ${params.relativePath}: ${result.failure.reason}`],
|
||||
};
|
||||
}
|
||||
const raw = JSON.parse(fs.readFileSync(opened.fd, "utf-8")) as unknown;
|
||||
const servers = extractMcpServerMap(raw);
|
||||
const baseDir = normalizeBundlePath(path.dirname(absolutePath));
|
||||
return {
|
||||
config: { mcpServers: {} },
|
||||
diagnostics: [`unable to read ${params.relativePath}: ${result.error}`],
|
||||
};
|
||||
}
|
||||
const servers = extractMcpServerMap(result.value);
|
||||
const baseDir = normalizeBundlePath(path.dirname(absolutePath));
|
||||
return {
|
||||
config: {
|
||||
mcpServers: Object.fromEntries(
|
||||
Object.entries(servers).map(([serverName, server]) => [
|
||||
serverName,
|
||||
absolutizeBundleMcpServer({ rootDir, baseDir, server }),
|
||||
]),
|
||||
),
|
||||
};
|
||||
} finally {
|
||||
fs.closeSync(opened.fd);
|
||||
}
|
||||
},
|
||||
diagnostics: [],
|
||||
};
|
||||
}
|
||||
|
||||
function loadBundleInlineMcpConfig(params: {
|
||||
@@ -243,14 +249,14 @@ function loadBundleMcpConfig(params: {
|
||||
rootDir: params.rootDir,
|
||||
bundleFormat: params.bundleFormat,
|
||||
});
|
||||
const diagnostics: string[] = [];
|
||||
for (const relativePath of filePaths) {
|
||||
merged = applyMergePatch(
|
||||
merged,
|
||||
loadBundleFileBackedMcpConfig({
|
||||
rootDir: params.rootDir,
|
||||
relativePath,
|
||||
}),
|
||||
) as BundleMcpConfig;
|
||||
const loaded = loadBundleFileBackedMcpConfig({
|
||||
rootDir: params.rootDir,
|
||||
relativePath,
|
||||
});
|
||||
diagnostics.push(...loaded.diagnostics);
|
||||
merged = applyMergePatch(merged, loaded.config) as BundleMcpConfig;
|
||||
}
|
||||
|
||||
merged = applyMergePatch(
|
||||
@@ -261,7 +267,7 @@ function loadBundleMcpConfig(params: {
|
||||
}),
|
||||
) as BundleMcpConfig;
|
||||
|
||||
return { config: merged, diagnostics: [] };
|
||||
return { config: merged, diagnostics };
|
||||
}
|
||||
|
||||
export function inspectBundleMcpRuntimeSupport(params: {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { PluginInstallRecord } from "../config/types.plugins.js";
|
||||
import { openRootFileSync } from "../infra/boundary-file-read.js";
|
||||
import { readRootJsonObjectSync } from "../infra/json-files.js";
|
||||
import { tryReadJsonSync } from "../infra/json-files.js";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
@@ -415,25 +415,14 @@ function readPackageManifest(
|
||||
rejectHardlinks = true,
|
||||
rootRealPath?: string,
|
||||
): PackageManifest | null {
|
||||
const manifestPath = path.join(dir, "package.json");
|
||||
const opened = openRootFileSync({
|
||||
absolutePath: manifestPath,
|
||||
rootPath: dir,
|
||||
const result = readRootJsonObjectSync({
|
||||
rootDir: dir,
|
||||
...(rootRealPath !== undefined ? { rootRealPath } : {}),
|
||||
relativePath: "package.json",
|
||||
boundaryLabel: "plugin package directory",
|
||||
rejectHardlinks,
|
||||
});
|
||||
if (!opened.ok) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const raw = fs.readFileSync(opened.fd, "utf-8");
|
||||
return JSON.parse(raw) as PackageManifest;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
fs.closeSync(opened.fd);
|
||||
}
|
||||
return result.ok ? (result.value as PackageManifest) : null;
|
||||
}
|
||||
|
||||
function readTrustedPackageManifest(dir: string): PackageManifest | null {
|
||||
|
||||
Reference in New Issue
Block a user