fix(memory-wiki): route bridge CLI through gateway

Route Memory Wiki bridge-mode status, doctor, and bridge import CLI paths through Gateway RPC when bridge artifact reads are active, while preserving local/offline fallbacks.

Harden Gateway CLI rendering and imported-source writes: validate RPC response shapes, bound response strings before rendering/JSON serialization, sanitize/escape terminal-controlled output, avoid redundant JSON forwarding, and replace imported source pages through a temp-file rename path with symlink and hardlink regressions.

Fixes #65722
Fixes #65976
Fixes #66082
Fixes #67979
Fixes #68371
Fixes #68828
Fixes #69019
Fixes #70181
Fixes #70242
Fixes #70842

Thanks @moorsecopers99, @vincentkoc, and @prasad-yashdeep.
This commit is contained in:
Vincent Koc
2026-04-28 03:22:12 -07:00
committed by GitHub
parent f12dedb5c8
commit e2f3044b8f
7 changed files with 748 additions and 20 deletions

View File

@@ -300,6 +300,97 @@ describe("syncMemoryWikiBridgeSources", () => {
});
});
it("refuses to overwrite bridge source pages through vault symlinks", async () => {
const workspaceDir = await createBridgeWorkspace("symlink-workspace");
const { rootDir: vaultDir, config } = await createVault({
rootDir: nextCaseRoot("symlink-vault"),
config: {
vaultMode: "bridge",
bridge: {
enabled: true,
readMemoryArtifacts: true,
indexMemoryRoot: true,
},
},
});
const memoryPath = path.join(workspaceDir, "MEMORY.md");
await fs.writeFile(memoryPath, "# Durable Memory\n", "utf8");
registerBridgeArtifacts([
{
kind: "memory-root",
workspaceDir,
relativePath: "MEMORY.md",
absolutePath: memoryPath,
agentIds: ["main"],
contentType: "markdown",
},
]);
const appConfig: OpenClawConfig = {
agents: {
list: [{ id: "main", default: true, workspace: workspaceDir }],
},
};
const first = await syncMemoryWikiBridgeSources({ config, appConfig });
const pagePath = first.pagePaths[0] ?? "";
const pageAbsPath = path.join(vaultDir, pagePath);
const externalTarget = path.join(workspaceDir, "outside.md");
await fs.writeFile(externalTarget, "external target\n", "utf8");
await fs.rm(pageAbsPath);
await fs.symlink(externalTarget, pageAbsPath);
await fs.writeFile(memoryPath, "# Updated Durable Memory\n", "utf8");
await expect(syncMemoryWikiBridgeSources({ config, appConfig })).rejects.toThrow(
"Refusing to write imported source page through symlink",
);
await expect(fs.readFile(externalTarget, "utf8")).resolves.toBe("external target\n");
});
it("replaces bridge source page hardlinks without clobbering their target", async () => {
const workspaceDir = await createBridgeWorkspace("hardlink-workspace");
const { rootDir: vaultDir, config } = await createVault({
rootDir: nextCaseRoot("hardlink-vault"),
config: {
vaultMode: "bridge",
bridge: {
enabled: true,
readMemoryArtifacts: true,
indexMemoryRoot: true,
},
},
});
const memoryPath = path.join(workspaceDir, "MEMORY.md");
await fs.writeFile(memoryPath, "# Durable Memory\n", "utf8");
registerBridgeArtifacts([
{
kind: "memory-root",
workspaceDir,
relativePath: "MEMORY.md",
absolutePath: memoryPath,
agentIds: ["main"],
contentType: "markdown",
},
]);
const appConfig: OpenClawConfig = {
agents: {
list: [{ id: "main", default: true, workspace: workspaceDir }],
},
};
const first = await syncMemoryWikiBridgeSources({ config, appConfig });
const pagePath = first.pagePaths[0] ?? "";
const pageAbsPath = path.join(vaultDir, pagePath);
const externalTarget = path.join(workspaceDir, "outside-hardlink.md");
await fs.writeFile(externalTarget, "external target\n", "utf8");
await fs.rm(pageAbsPath);
await fs.link(externalTarget, pageAbsPath);
await fs.writeFile(memoryPath, "# Updated Durable Memory\n", "utf8");
const second = await syncMemoryWikiBridgeSources({ config, appConfig });
expect(second.updatedCount).toBe(1);
await expect(fs.readFile(externalTarget, "utf8")).resolves.toBe("external target\n");
await expect(fs.readFile(pageAbsPath, "utf8")).resolves.toContain("# Updated Durable Memory");
});
it("caps composed bridge source filenames to the filesystem component limit", async () => {
const workspaceDir = await createBridgeWorkspace(`${"漢".repeat(50)}-workspace`);
const { rootDir: vaultDir, config } = await createVault({

View File

@@ -3,11 +3,25 @@ import os from "node:os";
import path from "node:path";
import { Command } from "commander";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { registerWikiCli, runWikiChatGptImport, runWikiChatGptRollback } from "./cli.js";
import {
registerWikiCli,
runWikiBridgeImport,
runWikiChatGptImport,
runWikiChatGptRollback,
runWikiDoctor,
runWikiStatus,
} from "./cli.js";
import type { MemoryWikiPluginConfig } from "./config.js";
import { parseWikiMarkdown, renderWikiMarkdown } from "./markdown.js";
import type { MemoryWikiDoctorReport, MemoryWikiStatus } from "./status.js";
import { createMemoryWikiTestHarness } from "./test-helpers.js";
const callGatewayFromCliMock = vi.hoisted(() => vi.fn());
vi.mock("openclaw/plugin-sdk/gateway-runtime", () => ({
callGatewayFromCli: callGatewayFromCliMock,
}));
const { createVault } = createMemoryWikiTestHarness();
let suiteRoot = "";
let caseIndex = 0;
@@ -24,6 +38,7 @@ describe("memory-wiki cli", () => {
});
beforeEach(() => {
callGatewayFromCliMock.mockReset();
vi.spyOn(process.stdout, "write").mockImplementation(
(() => true) as typeof process.stdout.write,
);
@@ -88,6 +103,45 @@ describe("memory-wiki cli", () => {
return exportDir;
}
function createGatewayStatus(config: {
vault: { path: string };
bridge: MemoryWikiStatus["bridge"];
}): MemoryWikiStatus {
return {
vaultMode: "bridge",
renderMode: "native",
vaultPath: config.vault.path,
vaultExists: true,
bridge: config.bridge,
bridgePublicArtifactCount: 2,
obsidianCli: {
enabled: false,
requested: false,
available: false,
command: null,
},
unsafeLocal: {
allowPrivateMemoryCoreAccess: false,
pathCount: 0,
},
pageCounts: {
source: 0,
entity: 0,
concept: 0,
synthesis: 0,
report: 0,
},
sourceCounts: {
native: 0,
bridge: 0,
bridgeEvents: 0,
unsafeLocal: 0,
other: 0,
},
warnings: [],
};
}
it("registers apply synthesis and writes a synthesis page", async () => {
const { rootDir, config } = await createCliVault();
const program = new Command();
@@ -193,6 +247,226 @@ cli note
await program.parseAsync(["wiki", "doctor", "--json"], { from: "user" });
expect(process.exitCode).toBe(1);
expect(callGatewayFromCliMock).not.toHaveBeenCalled();
});
it("routes active bridge status and doctor through the gateway", async () => {
const { config } = await createCliVault({
config: {
vaultMode: "bridge",
bridge: { enabled: true, readMemoryArtifacts: true },
},
initialize: true,
});
const status = createGatewayStatus(config);
const report: MemoryWikiDoctorReport = {
healthy: false,
warningCount: 1,
status: {
...status,
warnings: [
{
code: "bridge-artifacts-missing",
message: "No exported artifacts.",
},
],
},
fixes: [
{
code: "bridge-artifacts-missing",
message: "Create memory artifacts.",
},
],
};
callGatewayFromCliMock.mockResolvedValueOnce(status).mockResolvedValueOnce(report);
await expect(runWikiStatus({ config, json: true })).resolves.toBe(status);
await expect(runWikiDoctor({ config, json: true })).resolves.toBe(report);
expect(process.exitCode).toBe(1);
expect(callGatewayFromCliMock).toHaveBeenNthCalledWith(
1,
"wiki.status",
{ timeout: "30000" },
undefined,
{ progress: false },
);
expect(callGatewayFromCliMock).toHaveBeenNthCalledWith(
2,
"wiki.doctor",
{ timeout: "30000" },
undefined,
{ progress: false },
);
});
it("sanitizes gateway status text output without changing JSON output", async () => {
const { config } = await createCliVault({
config: {
vaultMode: "bridge",
bridge: { enabled: true, readMemoryArtifacts: true },
},
initialize: true,
});
const unsafeStatus = createGatewayStatus({
...config,
vault: { path: "\u001B[2J/tmp/wiki\nforged prompt\u202E" },
});
unsafeStatus.warnings = [
{
code: "bridge-artifacts-missing",
message: "missing artifacts\r\nfake success\u001B[31m\u202E",
},
];
const textOutput: string[] = [];
callGatewayFromCliMock.mockResolvedValueOnce(unsafeStatus);
await runWikiStatus({
config,
stdout: {
write: ((chunk: string) => textOutput.push(chunk) > 0) as NodeJS.WriteStream["write"],
},
});
const renderedText = textOutput.join("");
expect(renderedText).not.toContain("\u001B");
expect(renderedText).not.toContain("\u202E");
expect(renderedText).toContain("(/tmp/wiki forged prompt)");
expect(renderedText).toContain("- missing artifacts fake success");
const jsonOutput: string[] = [];
callGatewayFromCliMock.mockResolvedValueOnce(unsafeStatus);
await runWikiStatus({
config,
json: true,
stdout: {
write: ((chunk: string) => jsonOutput.push(chunk) > 0) as NodeJS.WriteStream["write"],
},
});
const renderedJson = jsonOutput.join("");
expect(renderedJson).not.toContain("\u001B");
expect(renderedJson).not.toContain("\u202E");
expect(renderedJson).not.toContain("\r");
expect(renderedJson).toContain("\\u001b[2J/tmp/wiki\\nforged prompt\\u202e");
expect(renderedJson).toContain("missing artifacts\\r\\nfake success\\u001b[31m\\u202e");
const parsed = JSON.parse(renderedJson) as MemoryWikiStatus;
expect(parsed.vaultPath).toBe("\u001B[2J/tmp/wiki\nforged prompt\u202E");
expect(parsed.warnings[0]?.message).toBe("missing artifacts\r\nfake success\u001B[31m\u202E");
});
it("rejects malformed gateway responses before rendering", async () => {
const { config } = await createCliVault({
config: {
vaultMode: "bridge",
bridge: { enabled: true, readMemoryArtifacts: true },
},
initialize: true,
});
callGatewayFromCliMock.mockResolvedValueOnce({ vaultMode: "bridge" });
await expect(runWikiStatus({ config })).rejects.toThrow(
"Invalid Gateway response for wiki.status.",
);
});
it("rejects oversized gateway strings before rendering", async () => {
const { config } = await createCliVault({
config: {
vaultMode: "bridge",
bridge: { enabled: true, readMemoryArtifacts: true },
},
initialize: true,
});
const status = createGatewayStatus(config);
status.warnings = [
{
code: "bridge-artifacts-missing",
message: "x".repeat(10_001),
},
];
callGatewayFromCliMock.mockResolvedValueOnce(status);
await expect(runWikiStatus({ config })).rejects.toThrow(
"Invalid Gateway response for wiki.status.",
);
});
it("truncates gateway status text output after rendering", async () => {
const { config } = await createCliVault({
config: {
vaultMode: "bridge",
bridge: { enabled: true, readMemoryArtifacts: true },
},
initialize: true,
});
const status = createGatewayStatus(config);
status.warnings = [
{
code: "bridge-artifacts-missing",
message: `${"warning ".repeat(500)}tail`,
},
];
const textOutput: string[] = [];
callGatewayFromCliMock.mockResolvedValueOnce(status);
await runWikiStatus({
config,
stdout: {
write: ((chunk: string) => textOutput.push(chunk) > 0) as NodeJS.WriteStream["write"],
},
});
const renderedText = textOutput.join("");
expect(renderedText).toContain("... [truncated]");
expect(renderedText).not.toContain("tail");
});
it("routes active bridge imports through the gateway and keeps disabled bridge imports local", async () => {
const active = await createCliVault({
config: {
vaultMode: "bridge",
bridge: { enabled: true, readMemoryArtifacts: true },
},
initialize: true,
});
callGatewayFromCliMock.mockResolvedValueOnce({
importedCount: 1,
updatedCount: 0,
skippedCount: 0,
removedCount: 0,
artifactCount: 1,
workspaces: 1,
pagePaths: ["sources/bridge-alpha.md"],
indexesRefreshed: true,
indexUpdatedFiles: ["index.md"],
indexRefreshReason: "import-changed",
});
const activeResult = await runWikiBridgeImport({ config: active.config, json: true });
expect(activeResult.importedCount).toBe(1);
expect(callGatewayFromCliMock).toHaveBeenCalledWith(
"wiki.bridge.import",
{ timeout: "30000" },
undefined,
{ progress: false },
);
callGatewayFromCliMock.mockClear();
const disabled = await createCliVault({
config: {
vaultMode: "bridge",
bridge: { enabled: false },
},
});
const disabledResult = await runWikiBridgeImport({ config: disabled.config, json: true });
expect(disabledResult.artifactCount).toBe(0);
expect(callGatewayFromCliMock).not.toHaveBeenCalled();
});
it("imports ChatGPT exports with dry-run, apply, and rollback", async () => {

View File

@@ -1,5 +1,6 @@
import fs from "node:fs/promises";
import type { Command } from "commander";
import { callGatewayFromCli } from "openclaw/plugin-sdk/gateway-runtime";
import type { OpenClawConfig } from "../api.js";
import { applyMemoryWikiMutation } from "./apply.js";
import {
@@ -27,14 +28,29 @@ import {
} from "./obsidian.js";
import { getMemoryWikiPage, searchMemoryWiki } from "./query.js";
import { syncMemoryWikiImportedSources } from "./source-sync.js";
import type { MemoryWikiImportedSourceSyncResult } from "./source-sync.js";
import {
buildMemoryWikiDoctorReport,
renderMemoryWikiDoctor,
renderMemoryWikiStatus,
type MemoryWikiDoctorReport,
type MemoryWikiStatus,
resolveMemoryWikiStatus,
} from "./status.js";
import { initializeMemoryWikiVault } from "./vault.js";
const WIKI_GATEWAY_TIMEOUT_MS = "30000";
const GATEWAY_TERMINAL_STRING_MAX_CHARS = 2_000;
const GATEWAY_RESPONSE_MAX_ARRAY_ITEMS = 10_000;
const GATEWAY_RESPONSE_MAX_STRING_CHARS = 10_000;
const GATEWAY_RESPONSE_MAX_CODE_CHARS = 256;
const ANSI_ESCAPE_SEQUENCE_PATTERN = new RegExp(
String.raw`(?:\x1B\[[0-?]*[ -/]*[@-~]|\x1B[@-Z\\-_]|\x9B[0-?]*[ -/]*[@-~])`,
"g",
);
const TERMINAL_CONTROL_CHARACTER_PATTERN = new RegExp(String.raw`[\x00-\x1F\x7F-\x9F]+`, "g");
const UNICODE_FORMAT_CONTROL_PATTERN = /[\u061C\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF]/g;
type WikiStatusCommandOptions = {
json?: boolean;
};
@@ -143,10 +159,173 @@ function isResolvedMemoryWikiConfig(
);
}
function sanitizeGatewayStringForTerminal(value: string): string {
const truncated =
value.length > GATEWAY_TERMINAL_STRING_MAX_CHARS
? value.slice(0, GATEWAY_TERMINAL_STRING_MAX_CHARS)
: value;
const sanitized = truncated
.replace(ANSI_ESCAPE_SEQUENCE_PATTERN, "")
.replace(TERMINAL_CONTROL_CHARACTER_PATTERN, " ")
.replace(UNICODE_FORMAT_CONTROL_PATTERN, "");
return value.length > GATEWAY_TERMINAL_STRING_MAX_CHARS
? `${sanitized}... [truncated]`
: sanitized;
}
function escapeGatewayJsonForTerminal(json: string): string {
return json.replace(UNICODE_FORMAT_CONTROL_PATTERN, (char) => {
const codePoint = char.codePointAt(0);
return typeof codePoint === "number" ? `\\u${codePoint.toString(16).padStart(4, "0")}` : "";
});
}
function writeOutput(output: string, writer: Pick<NodeJS.WriteStream, "write"> = process.stdout) {
writer.write(output.endsWith("\n") ? output : `${output}\n`);
}
function shouldRouteBridgeRuntimeThroughGateway(config: ResolvedMemoryWikiConfig): boolean {
return (
config.vaultMode === "bridge" && config.bridge.enabled && config.bridge.readMemoryArtifacts
);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function isBoundedGatewayString(
value: unknown,
maxChars = GATEWAY_RESPONSE_MAX_STRING_CHARS,
): value is string {
return typeof value === "string" && value.length <= maxChars;
}
function isStringArray(
value: unknown,
maxChars = GATEWAY_RESPONSE_MAX_STRING_CHARS,
): value is string[] {
return (
Array.isArray(value) &&
value.length <= GATEWAY_RESPONSE_MAX_ARRAY_ITEMS &&
value.every((item) => isBoundedGatewayString(item, maxChars))
);
}
function hasNumberFields(value: Record<string, unknown>, keys: readonly string[]): boolean {
return keys.every((key) => typeof value[key] === "number");
}
function isWarningList(value: unknown): boolean {
return (
Array.isArray(value) &&
value.length <= GATEWAY_RESPONSE_MAX_ARRAY_ITEMS &&
value.every(
(item) =>
isRecord(item) &&
isBoundedGatewayString(item.code, GATEWAY_RESPONSE_MAX_CODE_CHARS) &&
isBoundedGatewayString(item.message),
)
);
}
function isMemoryWikiStatus(value: unknown): value is MemoryWikiStatus {
if (!isRecord(value)) {
return false;
}
const bridge = value.bridge;
const obsidianCli = value.obsidianCli;
const unsafeLocal = value.unsafeLocal;
const pageCounts = value.pageCounts;
const sourceCounts = value.sourceCounts;
return (
isBoundedGatewayString(value.vaultMode, GATEWAY_RESPONSE_MAX_CODE_CHARS) &&
isBoundedGatewayString(value.renderMode, GATEWAY_RESPONSE_MAX_CODE_CHARS) &&
isBoundedGatewayString(value.vaultPath) &&
typeof value.vaultExists === "boolean" &&
(typeof value.bridgePublicArtifactCount === "number" ||
value.bridgePublicArtifactCount === null) &&
isRecord(bridge) &&
typeof bridge.enabled === "boolean" &&
isRecord(obsidianCli) &&
typeof obsidianCli.enabled === "boolean" &&
typeof obsidianCli.requested === "boolean" &&
typeof obsidianCli.available === "boolean" &&
(isBoundedGatewayString(obsidianCli.command) || obsidianCli.command === null) &&
isRecord(unsafeLocal) &&
typeof unsafeLocal.allowPrivateMemoryCoreAccess === "boolean" &&
typeof unsafeLocal.pathCount === "number" &&
isRecord(pageCounts) &&
hasNumberFields(pageCounts, ["source", "entity", "concept", "synthesis", "report"]) &&
isRecord(sourceCounts) &&
hasNumberFields(sourceCounts, ["native", "bridge", "bridgeEvents", "unsafeLocal", "other"]) &&
isWarningList(value.warnings)
);
}
function isMemoryWikiDoctorReport(value: unknown): value is MemoryWikiDoctorReport {
return (
isRecord(value) &&
typeof value.healthy === "boolean" &&
typeof value.warningCount === "number" &&
isMemoryWikiStatus(value.status) &&
Array.isArray(value.fixes) &&
value.fixes.length <= GATEWAY_RESPONSE_MAX_ARRAY_ITEMS &&
value.fixes.every(
(item) =>
isRecord(item) &&
isBoundedGatewayString(item.code, GATEWAY_RESPONSE_MAX_CODE_CHARS) &&
isBoundedGatewayString(item.message),
)
);
}
function isMemoryWikiImportResult(value: unknown): value is MemoryWikiImportedSourceSyncResult {
return (
isRecord(value) &&
hasNumberFields(value, [
"importedCount",
"updatedCount",
"skippedCount",
"removedCount",
"artifactCount",
"workspaces",
]) &&
isStringArray(value.pagePaths) &&
typeof value.indexesRefreshed === "boolean" &&
isStringArray(value.indexUpdatedFiles) &&
isBoundedGatewayString(value.indexRefreshReason, GATEWAY_RESPONSE_MAX_CODE_CHARS)
);
}
function validateWikiGatewayResult(
method: "wiki.status" | "wiki.doctor" | "wiki.bridge.import",
value: unknown,
): MemoryWikiStatus | MemoryWikiDoctorReport | MemoryWikiImportedSourceSyncResult {
if (method === "wiki.status" && isMemoryWikiStatus(value)) {
return value;
}
if (method === "wiki.doctor" && isMemoryWikiDoctorReport(value)) {
return value;
}
if (method === "wiki.bridge.import" && isMemoryWikiImportResult(value)) {
return value;
}
throw new Error(`Invalid Gateway response for ${method}.`);
}
async function callWikiGateway(method: "wiki.status"): Promise<MemoryWikiStatus>;
async function callWikiGateway(method: "wiki.doctor"): Promise<MemoryWikiDoctorReport>;
async function callWikiGateway(
method: "wiki.bridge.import",
): Promise<MemoryWikiImportedSourceSyncResult>;
async function callWikiGateway(method: "wiki.status" | "wiki.doctor" | "wiki.bridge.import") {
const result = await callGatewayFromCli(method, { timeout: WIKI_GATEWAY_TIMEOUT_MS }, undefined, {
progress: false,
});
return validateWikiGatewayResult(method, result);
}
function normalizeCliStringList(values?: string[]): string[] | undefined {
if (!values) {
return undefined;
@@ -201,6 +380,16 @@ function formatJsonOrText<T>(
return json ? JSON.stringify(result, null, 2) : render(result);
}
function formatGatewayJsonOrText<T>(
result: T,
json: boolean | undefined,
render: (result: T) => string,
): string {
return json
? escapeGatewayJsonForTerminal(JSON.stringify(result, null, 2))
: sanitizeGatewayStringForTerminal(render(result));
}
async function runWikiCommandWithSummary<T>(params: {
json?: boolean;
stdout?: Pick<NodeJS.WriteStream, "write">;
@@ -255,12 +444,19 @@ export async function runWikiStatus(params: {
json?: boolean;
stdout?: Pick<NodeJS.WriteStream, "write">;
}) {
await syncMemoryWikiImportedSources({ config: params.config, appConfig: params.appConfig });
const status = await resolveMemoryWikiStatus(params.config, {
appConfig: params.appConfig,
});
const routeThroughGateway = shouldRouteBridgeRuntimeThroughGateway(params.config);
const status = routeThroughGateway
? await callWikiGateway("wiki.status")
: await (async () => {
await syncMemoryWikiImportedSources({ config: params.config, appConfig: params.appConfig });
return await resolveMemoryWikiStatus(params.config, {
appConfig: params.appConfig,
});
})();
writeOutput(
params.json ? JSON.stringify(status, null, 2) : renderMemoryWikiStatus(status),
routeThroughGateway
? formatGatewayJsonOrText(status, params.json, renderMemoryWikiStatus)
: formatJsonOrText(status, params.json, renderMemoryWikiStatus),
params.stdout,
);
return status;
@@ -272,17 +468,24 @@ export async function runWikiDoctor(params: {
json?: boolean;
stdout?: Pick<NodeJS.WriteStream, "write">;
}) {
await syncMemoryWikiImportedSources({ config: params.config, appConfig: params.appConfig });
const report = buildMemoryWikiDoctorReport(
await resolveMemoryWikiStatus(params.config, {
appConfig: params.appConfig,
}),
);
const routeThroughGateway = shouldRouteBridgeRuntimeThroughGateway(params.config);
const report = routeThroughGateway
? await callWikiGateway("wiki.doctor")
: await (async () => {
await syncMemoryWikiImportedSources({ config: params.config, appConfig: params.appConfig });
return buildMemoryWikiDoctorReport(
await resolveMemoryWikiStatus(params.config, {
appConfig: params.appConfig,
}),
);
})();
if (!report.healthy) {
process.exitCode = 1;
}
writeOutput(
params.json ? JSON.stringify(report, null, 2) : renderMemoryWikiDoctor(report),
routeThroughGateway
? formatGatewayJsonOrText(report, params.json, renderMemoryWikiDoctor)
: formatJsonOrText(report, params.json, renderMemoryWikiDoctor),
params.stdout,
);
return report;
@@ -505,6 +708,13 @@ export async function runWikiBridgeImport(params: {
json?: boolean;
stdout?: Pick<NodeJS.WriteStream, "write">;
}) {
const render = (value: MemoryWikiImportedSourceSyncResult) =>
`Bridge import synced ${value.artifactCount} artifacts across ${value.workspaces} workspaces (${value.importedCount} new, ${value.updatedCount} updated, ${value.skippedCount} unchanged, ${value.removedCount} removed). Indexes ${value.indexesRefreshed ? `refreshed (${value.indexUpdatedFiles.length} files)` : `not refreshed (${value.indexRefreshReason})`}.`;
if (shouldRouteBridgeRuntimeThroughGateway(params.config)) {
const result = await callWikiGateway("wiki.bridge.import");
writeOutput(formatGatewayJsonOrText(result, params.json, render), params.stdout);
return result;
}
return runWikiCommandWithSummary({
json: params.json,
stdout: params.stdout,
@@ -513,8 +723,7 @@ export async function runWikiBridgeImport(params: {
config: params.config,
appConfig: params.appConfig,
}),
render: (value) =>
`Bridge import synced ${value.artifactCount} artifacts across ${value.workspaces} workspaces (${value.importedCount} new, ${value.updatedCount} updated, ${value.skippedCount} unchanged, ${value.removedCount} removed). Indexes ${value.indexesRefreshed ? `refreshed (${value.indexUpdatedFiles.length} files)` : `not refreshed (${value.indexRefreshReason})`}.`,
render,
});
}

View File

@@ -1,6 +1,7 @@
import { randomUUID } from "node:crypto";
import { constants as fsConstants } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { pathExists } from "./source-path-shared.js";
import {
setImportedSourceEntry,
shouldSkipImportedSourceWrite,
@@ -8,6 +9,123 @@ import {
} from "./source-sync-state.js";
type ImportedSourceState = Parameters<typeof shouldSkipImportedSourceWrite>[0]["state"];
type FileStats = Awaited<ReturnType<typeof fs.lstat>>;
function isPathInside(parent: string, child: string): boolean {
const relative = path.relative(parent, child);
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
async function resolveWritableVaultPagePath(params: {
vaultRoot: string;
pagePath: string;
}): Promise<{
pageAbsPath: string;
pageDir: string;
pageDirRealPath: string;
vaultRealPath: string;
existing: FileStats | null;
}> {
const vaultAbsPath = path.resolve(params.vaultRoot);
const pageAbsPath = path.resolve(vaultAbsPath, params.pagePath);
if (!isPathInside(vaultAbsPath, pageAbsPath)) {
throw new Error(`Refusing to write imported source page outside vault: ${params.pagePath}`);
}
const vaultRealPath = await fs.realpath(vaultAbsPath);
const pageDir = path.dirname(pageAbsPath);
await fs.mkdir(pageDir, { recursive: true });
const pageDirRealPath = await fs.realpath(pageDir);
if (!isPathInside(vaultRealPath, pageDirRealPath)) {
throw new Error(`Refusing to write imported source page outside vault: ${params.pagePath}`);
}
const existing = await fs.lstat(pageAbsPath).catch((err: unknown) => {
if ((err as NodeJS.ErrnoException)?.code === "ENOENT") {
return null;
}
throw err;
});
if (existing?.isSymbolicLink()) {
throw new Error(`Refusing to write imported source page through symlink: ${params.pagePath}`);
}
if (existing && !existing.isFile()) {
throw new Error(`Refusing to write imported source page over non-file: ${params.pagePath}`);
}
return { pageAbsPath, pageDir, pageDirRealPath, vaultRealPath, existing };
}
async function assertWritablePageDir(params: {
pageDir: string;
pageDirRealPath: string;
vaultRealPath: string;
pagePath: string;
}): Promise<void> {
const currentPageDirRealPath = await fs.realpath(params.pageDir);
if (
currentPageDirRealPath !== params.pageDirRealPath ||
!isPathInside(params.vaultRealPath, currentPageDirRealPath)
) {
throw new Error(`Refusing to write imported source page outside vault: ${params.pagePath}`);
}
}
async function validateDestinationForReplace(filePath: string, pagePath: string): Promise<void> {
const existing = await fs.lstat(filePath).catch((err: unknown) => {
if ((err as NodeJS.ErrnoException)?.code === "ENOENT") {
return null;
}
throw err;
});
if (existing?.isSymbolicLink()) {
throw new Error(`Refusing to write imported source page through symlink: ${pagePath}`);
}
if (existing && !existing.isFile()) {
throw new Error(`Refusing to write imported source page over non-file: ${pagePath}`);
}
}
async function writeFileAtomicInVault(params: {
filePath: string;
pageDir: string;
pageDirRealPath: string;
vaultRealPath: string;
pagePath: string;
content: string;
}): Promise<void> {
const noFollow = fsConstants.O_NOFOLLOW ?? 0;
await assertWritablePageDir(params);
const tempPath = path.join(params.pageDir, `.openclaw-wiki-${process.pid}-${randomUUID()}.tmp`);
let shouldRemoveTemp = true;
try {
const handle = await fs.open(
tempPath,
fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | noFollow,
0o600,
);
try {
const tempStat = await handle.stat();
if (!tempStat.isFile() || tempStat.nlink !== 1) {
throw new Error(
`Refusing to write imported source page through unsafe temp file: ${params.pagePath}`,
);
}
await handle.writeFile(params.content, "utf8");
} finally {
await handle.close();
}
await assertWritablePageDir(params);
await validateDestinationForReplace(params.filePath, params.pagePath);
await fs.rename(tempPath, params.filePath);
shouldRemoveTemp = false;
await assertWritablePageDir(params);
} finally {
if (shouldRemoveTemp) {
await fs.rm(tempPath, { force: true });
}
}
}
export async function writeImportedSourcePage(params: {
vaultRoot: string;
@@ -21,8 +139,17 @@ export async function writeImportedSourcePage(params: {
state: ImportedSourceState;
buildRendered: (raw: string, updatedAt: string) => string;
}): Promise<{ pagePath: string; changed: boolean; created: boolean }> {
const pageAbsPath = path.join(params.vaultRoot, params.pagePath);
const created = !(await pathExists(pageAbsPath));
const {
pageAbsPath,
pageDir,
pageDirRealPath,
vaultRealPath,
existing: pageStat,
} = await resolveWritableVaultPagePath({
vaultRoot: params.vaultRoot,
pagePath: params.pagePath,
});
const created = !pageStat;
const updatedAt = new Date(params.sourceUpdatedAtMs).toISOString();
const shouldSkip = await shouldSkipImportedSourceWrite({
vaultRoot: params.vaultRoot,
@@ -40,9 +167,16 @@ export async function writeImportedSourcePage(params: {
const raw = await fs.readFile(params.sourcePath, "utf8");
const rendered = params.buildRendered(raw, updatedAt);
const existing = await fs.readFile(pageAbsPath, "utf8").catch(() => "");
const existing = pageStat ? await fs.readFile(pageAbsPath, "utf8").catch(() => "") : "";
if (existing !== rendered) {
await fs.writeFile(pageAbsPath, rendered, "utf8");
await writeFileAtomicInVault({
filePath: pageAbsPath,
pageDir,
pageDirRealPath,
vaultRealPath,
pagePath: params.pagePath,
content: rendered,
});
}
setImportedSourceEntry({