openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
605 lines (604 loc) • 23.3 kB
JavaScript
import "./src-vebZIeLe.js";
import { a as asOptionalRecord } from "./record-coerce-DItp3I4t.js";
import { n as safeParseJsonRecord } from "./json-coercion-AulM0PZ6.js";
import { r as resolveHomeRelativePath } from "./home-dir-BPhrG-aM.js";
import { n as parseBooleanValue } from "./boolean-DmBL0YJK.js";
import { t as isBlockedObjectKey } from "./prototype-keys-CuYw53fZ.js";
import { r as isMissingPathError } from "./errno-CkbDOfLk.js";
import { E as INCLUDE_KEY } from "./redact-BtvPPfTi.js";
import { f as resolveConfigPath, w as resolveStateDir } from "./paths-D2sRr1a_.js";
import { t as isChannelConfigMetadataKey } from "./config-metadata-aX1D2IMg.js";
import "./errors-Db3Ymjlb.js";
import { t as formatDiagnosticFilenameTimestamp } from "./timestamps-CD_Oo5x3.js";
import { i as readRegularFileSync } from "./regular-file-Equ4rrfE.js";
import { t as VERSION } from "./version-v1kuAkGj.js";
import { l as parseConfigJson5 } from "./io.read-helpers-ZKp-UiGx.js";
import "./io-bdCzpGWJ.js";
import { a as readLatestDiagnosticStabilityBundleSync, i as readDiagnosticStabilityBundleFileSync } from "./diagnostic-stability-bundle-Bd3zVis2.js";
import { n as redactConfigObject } from "./redact-snapshot-DcfZQ78H.js";
import { a as sanitizeSupportSnapshotValue, i as sanitizeSupportConfigValue, n as redactSupportString, r as redactTextForSupport, t as redactPathForSupport } from "./diagnostic-support-redaction-DJtn51OP.js";
import { t as buildConfigSchemaCore } from "./schema-DyKN69ex.js";
import { t as readConfiguredLogTail } from "./log-tail-Nn_FQ1QS.js";
import { i as textSupportBundleFile, n as jsonlSupportBundleFile, o as writeSupportBundleZip, r as supportBundleContents, t as jsonSupportBundleFile } from "./diagnostic-support-bundle-C-YIJlsI.js";
import fs from "node:fs";
import process from "node:process";
import path from "node:path";
//#region src/logging/diagnostic-support-log-redaction.ts
const LOG_STRING_FIELD_RE = /^(?:action|channel|code|component|endpoint|event|handshake|kind|level|localAddr|logger|method|model|module|msg|name|outcome|phase|pluginId|provider|reason|remoteAddr|requestId|runId|service|source|status|subsystem|surface|target|time|traceId|type)$/iu;
const LOG_SCALAR_FIELD_RE = /^(?:active|attempt|bytes|count|durationMs|enabled|exitCode|intervalMs|jobs|limitBytes|localPort|nextWakeAtMs|pid|port|queueDepth|queued|remotePort|statusCode|waitMs|waiting)$/iu;
const OMITTED_LOG_FIELD_RE = /(?:authorization|body|chat|content|cookie|credential|detail|error|header|instruction|message|password|payload|prompt|result|secret|session[-_]?id|session[-_]?key|text|token|tool|transcript|url)/iu;
const UNSAFE_LOG_MESSAGE_RE = /(?:\blastAssistant\s*=|\b(?:ai response|assistant said|chat text|message contents|prompt|raw webhook body|tool output|tool result|transcript|user said|webhook body)\b|auto-responding\b.*:\s*["']|partial for\b.*:)/iu;
const MAX_LOG_STRING_LENGTH = 240;
const LOGTAPE_META_FIELD = "_meta";
const LOGTAPE_ARG_FIELD_RE = /^\d+$/u;
const LOGTAPE_META_STRING_FIELDS = /* @__PURE__ */ new Map([["logLevelName", "level"], ["name", "logger"]]);
function byteLength(content) {
return Buffer.byteLength(content, "utf8");
}
function createLogRecord() {
return Object.create(null);
}
/** Parses and sanitizes one log line into safe support-bundle metadata. */
function sanitizeSupportLogRecord(line, redaction) {
let parsed;
try {
parsed = JSON.parse(line);
} catch {
return {
omitted: "unparsed",
bytes: byteLength(line)
};
}
const source = asOptionalRecord(parsed);
if (!source) return {
omitted: "non-object",
bytes: byteLength(line)
};
const sanitized = createLogRecord();
addNamedLogFields(sanitized, source, redaction);
addLogTapeMetaFields(sanitized, source, redaction);
addLogTapeArgFields(sanitized, source, redaction);
return Object.keys(sanitized).length > 0 ? sanitized : {
omitted: "no-safe-fields",
bytes: byteLength(line)
};
}
function addNamedLogFields(sanitized, source, redaction) {
for (const [key, value] of Object.entries(source)) {
if (key === LOGTAPE_META_FIELD || LOGTAPE_ARG_FIELD_RE.test(key)) continue;
addSafeLogField(sanitized, key, value, redaction);
}
}
function addLogTapeMetaFields(sanitized, source, redaction) {
const meta = asOptionalRecord(source[LOGTAPE_META_FIELD]);
if (!meta) return;
for (const [sourceKey, outputKey] of LOGTAPE_META_STRING_FIELDS) {
if (sanitized[outputKey] !== void 0) continue;
const value = meta[sourceKey];
if (typeof value === "string") {
if (sourceKey === "name") {
const record = parseJsonRecord(value);
if (record) {
addLogObjectFields(sanitized, record, redaction);
continue;
}
}
sanitized[outputKey] = sanitizeLogString(value, redaction);
}
}
}
function addLogTapeArgFields(sanitized, source, redaction) {
const args = Object.entries(source).filter(([key]) => LOGTAPE_ARG_FIELD_RE.test(key)).toSorted(([left], [right]) => Number(left) - Number(right));
for (const [, value] of args) {
const record = typeof value === "string" ? parseJsonRecord(value) : asOptionalRecord(value);
if (record) {
addLogObjectFields(sanitized, record, redaction);
continue;
}
if (typeof value === "string") addLogTapeMessageField(sanitized, value, redaction);
}
}
function addLogTapeMessageField(sanitized, value, redaction) {
const message = sanitizeLogString(value, redaction);
if (sanitized.msg === void 0 && message && !UNSAFE_LOG_MESSAGE_RE.test(message)) {
sanitized.msg = message;
return;
}
addOmittedLogMessageMetadata(sanitized, value);
}
function addOmittedLogMessageMetadata(sanitized, value) {
sanitized.omitted = "log-message";
sanitized.omittedLogMessageBytes = numericLogMetadata(sanitized.omittedLogMessageBytes) + byteLength(value);
sanitized.omittedLogMessageCount = numericLogMetadata(sanitized.omittedLogMessageCount) + 1;
}
function numericLogMetadata(value) {
return typeof value === "number" && Number.isFinite(value) ? value : 0;
}
function parseJsonRecord(value) {
const trimmed = value.trim();
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return;
return safeParseJsonRecord(trimmed);
}
function addLogObjectFields(sanitized, source, redaction) {
for (const [key, value] of Object.entries(source)) addSafeLogField(sanitized, key, value, redaction);
}
function addSafeLogField(sanitized, key, value, redaction) {
if (OMITTED_LOG_FIELD_RE.test(key)) return;
if (isBlockedObjectKey(key)) return;
if (!isSafeLogField(key, value)) return;
if (typeof value === "string") {
const message = sanitizeLogString(value, redaction);
if (key === "msg" && (!message || UNSAFE_LOG_MESSAGE_RE.test(message))) {
addOmittedLogMessageMetadata(sanitized, value);
return;
}
sanitized[key] = message;
} else if (typeof value === "number" || typeof value === "boolean" || value === null) sanitized[key] = value;
}
function sanitizeLogString(value, redaction) {
return redactSupportString(value, redaction, {
maxLength: MAX_LOG_STRING_LENGTH,
truncationSuffix: ""
});
}
function isSafeLogField(key, value) {
if (typeof value === "string") return LOG_STRING_FIELD_RE.test(key);
return LOG_STRING_FIELD_RE.test(key) || LOG_SCALAR_FIELD_RE.test(key);
}
//#endregion
//#region src/logging/diagnostic-support-export.ts
const DIAGNOSTIC_SUPPORT_EXPORT_VERSION = 1;
const DEFAULT_LOG_LIMIT = 5e3;
const DEFAULT_LOG_MAX_BYTES = 1e6;
const SUPPORT_EXPORT_CONFIG_MAX_BYTES = 8388608;
const SUPPORT_EXPORT_PREFIX = "openclaw-diagnostics-";
const SUPPORT_EXPORT_SUFFIX = ".zip";
function normalizePositiveInteger(value, fallback) {
const parsed = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(parsed) || parsed < 1) return fallback;
return Math.floor(parsed);
}
function safeScalar(value) {
if (typeof value === "boolean") return value;
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string") return redactTextForSupport(value) === value && /^[A-Za-z0-9_.:-]{1,120}$/u.test(value) ? value : "<redacted>";
}
function resolveBonjourEnvOverride(env) {
const raw = env.OPENCLAW_DISABLE_BONJOUR?.trim();
if (!raw) return "unset";
const disabled = parseBooleanValue(raw);
if (disabled === true) return "force-disabled";
return disabled === false ? "force-enabled" : "unrecognized";
}
function sortedConfigEntryKeys(value) {
return Object.keys(asOptionalRecord(value) ?? {}).filter((key) => key !== INCLUDE_KEY).toSorted((a, b) => a.localeCompare(b));
}
function sanitizeConfigShape(parsed, configPath, stat, env) {
const root = asOptionalRecord(parsed) ?? {};
const gateway = asOptionalRecord(root.gateway);
const auth = asOptionalRecord(gateway?.auth);
const discovery = asOptionalRecord(root.discovery);
const mdns = asOptionalRecord(discovery?.mdns);
const channels = asOptionalRecord(root.channels);
const plugins = asOptionalRecord(root.plugins);
const agents = asOptionalRecord(asOptionalRecord(root.agents)?.entries);
const shape = {
path: configPath,
exists: true,
parseOk: true,
bytes: stat.size,
mtime: stat.mtime.toISOString(),
topLevelKeys: Object.keys(root).toSorted((a, b) => a.localeCompare(b))
};
if (gateway) shape.gateway = {
mode: safeScalar(gateway.mode),
bind: safeScalar(gateway.bind),
port: safeScalar(gateway.port),
authMode: safeScalar(auth?.mode),
tailscale: safeScalar(asOptionalRecord(gateway.tailscale)?.mode)
};
const bonjourEnvOverride = resolveBonjourEnvOverride(env);
if (mdns || bonjourEnvOverride !== "unset") shape.discovery = {
mdnsMode: safeScalar(mdns?.mode),
bonjourEnvOverride
};
if (channels) {
const ids = sortedConfigEntryKeys(channels).filter((key) => !isChannelConfigMetadataKey(key));
shape.channels = {
count: ids.length,
ids
};
}
if (plugins) {
const ids = sortedConfigEntryKeys(plugins.entries);
shape.plugins = {
count: ids.length,
ids
};
}
if (agents) shape.agents = { count: sortedConfigEntryKeys(agents).length };
return shape;
}
function sanitizeConfigDetails(parsed, redaction) {
return sanitizeSupportConfigValue(redactConfigObject(parsed, buildConfigSchemaCore().uiHints), redaction);
}
function configShapeReadFailure(params) {
const shape = {
path: params.configPath,
exists: Boolean(params.stat),
parseOk: false,
topLevelKeys: []
};
if (params.stat) {
shape.bytes = params.stat.size;
shape.mtime = params.stat.mtime.toISOString();
}
if (params.error) shape.error = redactSupportString(params.error, params.redaction);
return shape;
}
function configReadErrorMessage(error, stat) {
if (!stat && isMissingPathError(error)) return;
return error instanceof Error ? error.message : String(error);
}
function readConfigExport(options) {
const redactedConfigPath = redactPathForSupport(options.configPath, options);
let stat;
try {
stat = fs.statSync(options.configPath);
const { buffer } = readRegularFileSync({
filePath: options.configPath,
maxBytes: SUPPORT_EXPORT_CONFIG_MAX_BYTES
});
const parsed = parseConfigJson5(buffer.toString("utf8"));
if (!parsed.ok) return { shape: configShapeReadFailure({
configPath: redactedConfigPath,
redaction: options,
stat,
error: parsed.error
}) };
return {
shape: sanitizeConfigShape(parsed.parsed, redactedConfigPath, stat, options.env),
sanitized: sanitizeConfigDetails(parsed.parsed, options)
};
} catch (error) {
return { shape: configShapeReadFailure({
configPath: redactedConfigPath,
redaction: options,
stat,
error: configReadErrorMessage(error, stat)
}) };
}
}
function redactErrorForSupport(error, redaction) {
return redactSupportString(error instanceof Error ? error.message : String(error), redaction);
}
async function collectSupportSnapshot(params) {
if (!params.reader) return { summary: { status: "skipped" } };
try {
const data = await params.reader();
return {
summary: {
status: "included",
path: params.path
},
file: jsonSupportBundleFile(params.path, {
status: "ok",
capturedAt: params.generatedAt,
data: sanitizeSupportSnapshotValue(data, params.redaction)
})
};
} catch (error) {
const redactedError = redactErrorForSupport(error, params.redaction);
return {
summary: {
status: "failed",
path: params.path,
error: redactedError
},
file: jsonSupportBundleFile(params.path, {
status: "failed",
capturedAt: params.generatedAt,
error: redactedError
})
};
}
}
function readStabilityBundle(target, stateDir) {
if (target === false) return {
status: "missing",
dir: "$OPENCLAW_STATE_DIR/logs/stability"
};
if (target === void 0 || target === "latest") return readLatestDiagnosticStabilityBundleSync({ stateDir });
return readDiagnosticStabilityBundleFileSync(target);
}
function sanitizeLogTail(tail, options) {
return {
status: "included",
file: redactPathForSupport(tail.file, options),
cursor: tail.cursor,
size: tail.size,
lineCount: tail.lines.length,
truncated: tail.truncated,
reset: tail.reset,
lines: tail.lines.map((line) => sanitizeSupportLogRecord(line, options))
};
}
function failedLogTail(error, redaction) {
const redactedError = redactErrorForSupport(error, redaction);
return {
status: "failed",
file: "unavailable",
cursor: 0,
size: 0,
lineCount: 0,
truncated: false,
reset: false,
error: redactedError,
lines: [{
omitted: "log-tail-read-failed",
error: redactedError
}]
};
}
function logString(record, key) {
const value = record[key];
return typeof value === "string" && value.trim() ? value : void 0;
}
function isBonjourLogRecord(record) {
if ([
"subsystem",
"logger",
"module",
"pluginId",
"component"
].some((field) => logString(record, field)?.toLowerCase().includes("bonjour"))) return true;
return logString(record, "msg")?.toLowerCase().startsWith("bonjour:") === true;
}
function classifyBonjourLogKind(normalizedMsg) {
if (normalizedMsg.includes("disabling")) return "disabled";
if (normalizedMsg.includes("restarting")) return "restarted";
if (normalizedMsg.includes("suppressing ciao")) return "ciao_suppressed";
if (normalizedMsg.includes("conflict")) return "conflict";
if (normalizedMsg.includes("watchdog")) return "watchdog";
return "other";
}
function summarizeBonjourLogs(logTail) {
const summary = {
count: 0,
warnings: 0,
flags: {
disabled: false,
restarted: false,
ciaoSuppressed: false
}
};
for (const record of logTail.lines) {
if (!isBonjourLogRecord(record)) continue;
summary.count += 1;
const level = logString(record, "level")?.toLowerCase();
if (level === "warn" || level === "error") summary.warnings += 1;
const normalizedMsg = logString(record, "msg")?.toLowerCase() ?? "";
summary.flags.disabled ||= normalizedMsg.includes("disabling");
summary.flags.restarted ||= normalizedMsg.includes("restarting");
summary.flags.ciaoSuppressed ||= normalizedMsg.includes("suppressing ciao");
summary.last = {
...logString(record, "time") ? { time: logString(record, "time") } : {},
...logString(record, "level") ? { level: logString(record, "level") } : {},
kind: classifyBonjourLogKind(normalizedMsg)
};
}
return summary;
}
async function collectSupportLogTail(params) {
try {
return sanitizeLogTail(await params.readLogTail({
limit: params.limit,
maxBytes: params.maxBytes
}), params.redaction);
} catch (error) {
return failedLogTail(error, params.redaction);
}
}
function describeStabilityForDiagnostics(stability, redaction) {
if (stability.status === "found") return {
status: "found",
path: redactPathForSupport(stability.path, redaction),
mtimeMs: stability.mtimeMs,
eventCount: stability.bundle.snapshot.count,
reason: stability.bundle.reason,
generatedAt: stability.bundle.generatedAt
};
if (stability.status === "missing") return {
status: "missing",
dir: redactPathForSupport(stability.dir, redaction)
};
return {
status: "failed",
path: stability.path ? redactPathForSupport(stability.path, redaction) : void 0,
error: redactErrorForSupport(stability.error, redaction)
};
}
function renderSummary(params) {
const stabilityLine = params.stability.status === "found" ? `included latest stability bundle (${params.stability.bundle.snapshot.count} event(s))` : `no stability bundle included (${params.stability.status})`;
const configLine = params.config.exists ? `config shape included (${params.config.parseOk ? "parsed" : "parse failed"})` : "config file not found";
const logTailLine = params.logTail.status === "failed" ? `sanitized log tail unavailable (${params.logTail.error})` : `sanitized log tail (${params.logTail.lineCount} line(s), inspected ${params.logTail.size} byte(s), raw messages omitted)`;
const supportSnapshotLine = (label, snapshot) => {
if (snapshot.status === "included") return `${label} snapshot included (${snapshot.path})`;
if (snapshot.status === "failed") return `${label} snapshot failed (${snapshot.error})`;
return `${label} snapshot skipped`;
};
return [
"# OpenClaw Diagnostics Export",
"",
"Attach this zip to the bug report. It is designed for maintainers to inspect without asking for raw logs first.",
"",
"## Generated",
"",
`Generated: ${params.generatedAt}`,
`OpenClaw: ${VERSION}`,
"",
"## Contents",
"",
`- ${stabilityLine}`,
`- ${logTailLine}`,
`- ${configLine}`,
`- ${supportSnapshotLine("gateway status", params.status)}`,
`- ${supportSnapshotLine("gateway health", params.health)}`,
"",
"## Maintainer Quick Read",
"",
"- `manifest.json`: file inventory and privacy notes",
"- `diagnostics.json`: top-level summary of config, logs, Bonjour/mDNS, stability, status, and health",
"- `config/sanitized.json`: config values with credentials, private identifiers, and prompt text redacted",
"- `status/gateway-status.json`: sanitized service/connectivity snapshot",
"- `health/gateway-health.json`: sanitized Gateway health snapshot",
"- `logs/openclaw-sanitized.jsonl`: sanitized log summaries and metadata",
"- `stability/latest.json`: newest payload-free stability bundle, when available",
"",
"## Privacy",
"",
"- raw chat text, webhook bodies, tool outputs, tokens, cookies, and secrets are not included intentionally",
"- log records keep operational summaries and safe metadata fields",
"- status and health snapshots redact secret fields, payload-like fields, and account/message identifiers",
"- config output keeps useful settings but redacts secrets, private identifiers, and prompt text"
].join("\n");
}
function defaultOutputPath(options) {
return path.join(options.stateDir, "logs", "support", `${SUPPORT_EXPORT_PREFIX}${formatDiagnosticFilenameTimestamp(options.now)}-${process.pid}${SUPPORT_EXPORT_SUFFIX}`);
}
function resolveOutputPath(options) {
const raw = options.outputPath?.trim();
if (!raw) return defaultOutputPath(options);
const resolved = path.isAbsolute(raw) || raw.startsWith("~") ? resolveHomeRelativePath(raw, { env: options.env }) : path.resolve(options.cwd, raw);
try {
if (fs.statSync(resolved).isDirectory()) return path.join(resolved, `${SUPPORT_EXPORT_PREFIX}${formatDiagnosticFilenameTimestamp(options.now)}-${process.pid}${SUPPORT_EXPORT_SUFFIX}`);
} catch {}
return resolved;
}
async function buildDiagnosticSupportExport(options = {}) {
const env = options.env ?? process.env;
const stateDir = options.stateDir ?? resolveStateDir(env);
const generatedAt = (options.now ?? /* @__PURE__ */ new Date()).toISOString();
const configPath = resolveConfigPath(env, stateDir);
const stability = readStabilityBundle(options.stabilityBundle, stateDir);
const redaction = {
env,
stateDir
};
const logTail = await collectSupportLogTail({
readLogTail: options.readLogTail ?? readConfiguredLogTail,
limit: normalizePositiveInteger(options.logLimit, DEFAULT_LOG_LIMIT),
maxBytes: normalizePositiveInteger(options.logMaxBytes, DEFAULT_LOG_MAX_BYTES),
redaction
});
const config = readConfigExport({
configPath,
env,
stateDir
});
const [statusSnapshot, healthSnapshot] = await Promise.all([collectSupportSnapshot({
path: "status/gateway-status.json",
reader: options.readStatusSnapshot,
generatedAt,
redaction
}), collectSupportSnapshot({
path: "health/gateway-health.json",
reader: options.readHealthSnapshot,
generatedAt,
redaction
})]);
const diagnostics = {
generatedAt,
openclawVersion: VERSION,
process: {
platform: process.platform,
arch: process.arch,
node: process.versions.node,
pid: process.pid
},
stateDir: redactPathForSupport(stateDir, redaction),
config: config.shape,
logs: {
file: logTail.file,
cursor: logTail.cursor,
size: logTail.size,
lineCount: logTail.lineCount,
truncated: logTail.truncated,
reset: logTail.reset
},
stability: describeStabilityForDiagnostics(stability, redaction),
bonjour: summarizeBonjourLogs(logTail),
status: statusSnapshot.summary,
health: healthSnapshot.summary
};
const files = [
jsonSupportBundleFile("diagnostics.json", diagnostics),
jsonSupportBundleFile("config/shape.json", config.shape),
jsonSupportBundleFile("config/sanitized.json", config.sanitized ?? null),
jsonlSupportBundleFile("logs/openclaw-sanitized.jsonl", logTail.lines.map((line) => JSON.stringify(line)))
];
for (const snapshot of [statusSnapshot, healthSnapshot]) if (snapshot.file) files.push(snapshot.file);
if (stability.status === "found") files.push(jsonSupportBundleFile("stability/latest.json", stability.bundle));
files.push(textSupportBundleFile("summary.md", renderSummary({
generatedAt,
stability,
logTail,
config: config.shape,
status: statusSnapshot.summary,
health: healthSnapshot.summary
})));
const manifest = {
version: DIAGNOSTIC_SUPPORT_EXPORT_VERSION,
generatedAt,
openclawVersion: VERSION,
platform: process.platform,
arch: process.arch,
node: process.versions.node,
stateDir: redactPathForSupport(stateDir, redaction),
contents: supportBundleContents(files),
privacy: {
payloadFree: true,
rawLogsIncluded: false,
notes: [
"Stability bundles are payload-free diagnostic snapshots.",
"Logs keep operational summaries and safe metadata fields; payload-like fields are omitted.",
"Status and health snapshots redact secrets, payload-like fields, and account/message identifiers.",
"Config output includes useful settings with credentials, private identifiers, and prompt text redacted."
]
}
};
return {
manifest,
files: [jsonSupportBundleFile("manifest.json", manifest), ...files]
};
}
async function writeDiagnosticSupportExport(options = {}) {
const env = options.env ?? process.env;
const stateDir = options.stateDir ?? resolveStateDir(env);
const now = options.now ?? /* @__PURE__ */ new Date();
const outputPath = resolveOutputPath({
outputPath: options.outputPath,
cwd: options.cwd ?? process.cwd(),
env,
stateDir,
now
});
const artifact = await buildDiagnosticSupportExport({
...options,
env,
stateDir,
now
});
const published = await writeSupportBundleZip({
outputPath,
files: artifact.files,
compressionLevel: 6
});
return {
path: published.path,
bytes: published.bytes,
manifest: artifact.manifest
};
}
//#endregion
export { writeDiagnosticSupportExport };