fix(channels): redact logs and reject unknown filters (#125939)

* fix(channels): redact and validate log filters

* fix(logging): preserve filtered redacted channel tails

* fix(channels): match log filters on exact boundaries

* fix(logging): honor redaction off mode
This commit is contained in:
Peter Steinberger
2026-08-18 13:37:17 -07:00
committed by GitHub
parent 1d36dac99d
commit f59392fdf5
10 changed files with 348 additions and 222 deletions
+17
View File
@@ -17,6 +17,9 @@ const listRawChannelPluginCatalogEntriesMock = vi.hoisted(() =>
vi.fn<() => ChannelPluginCatalogEntry[]>(() => []),
);
const channelsAddCommandMock = vi.hoisted(() => vi.fn(async () => undefined));
const channelsLogsCommandMock = vi.hoisted(() =>
vi.fn(async (_options: { channel?: string }, _runtime: unknown) => undefined),
);
const channelsResolveCommandMock = vi.hoisted(() => vi.fn(async () => undefined));
const runtimeMock = vi.hoisted(() => ({
log: vi.fn(),
@@ -34,6 +37,7 @@ vi.mock("../channels/plugins/catalog.js", () => ({
vi.mock("../commands/channels.js", () => ({
channelsAddCommand: channelsAddCommandMock,
channelsLogsCommand: channelsLogsCommandMock,
channelsResolveCommand: channelsResolveCommandMock,
}));
@@ -91,6 +95,19 @@ describe("registerChannelsCli", () => {
expect(getChannelSubcommandNames(program, "dead-letters")).toEqual(["list", "resubmit"]);
});
it.each([
["omitted", ["channels", "logs"], undefined],
["explicit all", ["channels", "logs", "--channel", "all"], "all"],
])("distinguishes an %s channels logs filter", async (_label, args, expectedChannel) => {
const program = new Command().name("openclaw").exitOverride();
await registerChannelsCli(program, ["node", "openclaw", ...args]);
await program.parseAsync(args, { from: "user" });
const [options] = channelsLogsCommandMock.mock.calls[0] ?? [];
expect(options?.channel).toBe(expectedChannel);
});
it.each(["auto", "user", "group", "channel"])(
"forwards the supported %s resolve target kind",
async (kind) => {
+1 -1
View File
@@ -257,7 +257,7 @@ export async function registerChannelsCli(
channels
.command("logs")
.description("Show recent channel logs from the gateway log file")
.option("--channel <name>", `Channel (${formatCliChannelOptions(["all"])})`, "all")
.option("--channel <name>", `Channel (${formatCliChannelOptions(["all"])}; default: all)`)
.option("--lines <n>", "Number of lines (default: 200)", "200")
.option("--json", "Output JSON", false)
.action(async (opts) => {
+160 -112
View File
@@ -4,17 +4,20 @@ import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { setLoggerOverride } from "../logging.js";
import { registerSecretValueForRedaction } from "../logging/secret-redaction-registry.js";
import { resetSecretRedactionRegistryForTest } from "../logging/secret-redaction-registry.test-support.js";
import { createTestRuntime } from "./test-runtime-config-helpers.js";
const pluginRegistryMocks = vi.hoisted(() => ({
loadPluginRegistrySnapshot: vi.fn(() => ({ plugins: [] })),
listPluginContributionIds: vi.fn(() => ["external-chat"]),
}));
const pluginRegistryMocks = vi.hoisted(() => {
const plugins = [{ id: "vendor-external-chat", channels: ["external-chat"] }];
return {
loadPluginManifestRegistryForPluginRegistry: vi.fn(() => ({ diagnostics: [], plugins })),
};
});
vi.mock("../plugins/plugin-registry.js", () => ({
loadPluginManifestRegistryForPluginRegistry: () => ({ diagnostics: [], plugins: [] }),
loadPluginRegistrySnapshot: pluginRegistryMocks.loadPluginRegistrySnapshot,
listPluginContributionIds: pluginRegistryMocks.listPluginContributionIds,
loadPluginManifestRegistryForPluginRegistry:
pluginRegistryMocks.loadPluginManifestRegistryForPluginRegistry,
}));
vi.mock("../channels/plugins/index.js", () => ({
@@ -26,20 +29,22 @@ vi.mock("../channels/plugins/index.js", () => ({
import { channelsLogsCommand } from "./channels/logs.js";
const runtime = createTestRuntime();
type PositionalRead = (
buffer: Buffer,
offset: number,
length: number,
position: number | null,
) => Promise<{ bytesRead: number; buffer: Buffer }>;
function logLine(params: { module: string; message: string }) {
function logLine(params: {
subsystem?: string;
module?: string;
plugin?: string;
message: string;
}) {
return JSON.stringify({
time: "2026-04-25T12:00:00.000Z",
0: params.message,
_meta: {
logLevelName: "INFO",
name: JSON.stringify({ module: params.module }),
name: JSON.stringify({
...(params.subsystem ? { subsystem: params.subsystem } : {}),
...(params.module ? { module: params.module } : {}),
...(params.plugin ? { plugin: params.plugin } : {}),
}),
},
});
}
@@ -48,7 +53,7 @@ function readJsonPayload() {
return JSON.parse(String(runtime.log.mock.calls[0]?.[0])) as {
file: string;
channel: string;
lines: Array<{ message: string }>;
lines: Array<{ message: string; raw: string }>;
};
}
@@ -63,12 +68,12 @@ describe("channelsLogsCommand", () => {
runtime.log.mockClear();
runtime.error.mockClear();
runtime.exit.mockClear();
pluginRegistryMocks.loadPluginRegistrySnapshot.mockClear();
pluginRegistryMocks.listPluginContributionIds.mockClear();
pluginRegistryMocks.loadPluginManifestRegistryForPluginRegistry.mockClear();
});
afterEach(async () => {
vi.restoreAllMocks();
resetSecretRedactionRegistryForTest();
setLoggerOverride(null);
await fs.rm(tempDir, { recursive: true, force: true });
});
@@ -77,24 +82,153 @@ describe("channelsLogsCommand", () => {
await fs.writeFile(
logPath,
[
logLine({ module: "gateway/channels/external-chat/send", message: "external sent" }),
logLine({ plugin: "vendor-external-chat", message: "external sent" }),
logLine({ plugin: "vendor-external-chat-shadow", message: "shadow sent" }),
logLine({ module: "gateway/channels/slack/send", message: "slack sent" }),
].join("\n"),
);
await channelsLogsCommand({ channel: "external-chat", json: true }, runtime);
expect(pluginRegistryMocks.loadPluginRegistrySnapshot).toHaveBeenCalledOnce();
expect(pluginRegistryMocks.listPluginContributionIds).toHaveBeenCalledOnce();
const [contributionOptions] = pluginRegistryMocks.listPluginContributionIds.mock
.calls[0] as unknown as [{ contribution?: string; includeDisabled?: boolean }];
expect(contributionOptions?.contribution).toBe("channels");
expect(contributionOptions?.includeDisabled).toBe(true);
expect(pluginRegistryMocks.loadPluginManifestRegistryForPluginRegistry).toHaveBeenCalledWith({
includeDisabled: true,
env: process.env,
});
const payload = readJsonPayload();
expect(payload.channel).toBe("external-chat");
expect(payload.lines.map((line) => line.message)).toEqual(["external sent"]);
});
it.each([
{
label: "subsystem",
channel: "slack",
shadow: { subsystem: "gateway/channels/slack-archive" },
match: { subsystem: "gateway/channels/slack/send" },
},
{
label: "module",
channel: "external-chat",
shadow: { module: "external-chat-shadow" },
match: { module: "external-chat" },
},
])("excludes a shadow $label while preserving an exact channel match", async (fixture) => {
await fs.writeFile(
logPath,
[
logLine({ ...fixture.shadow, message: "shadow" }),
logLine({ ...fixture.match, message: "match" }),
].join("\n"),
);
await channelsLogsCommand({ channel: fixture.channel, json: true }, runtime);
expect(readJsonPayload().lines.map((line) => line.message)).toEqual(["match"]);
});
it.each([false, true])(
"rejects an unknown explicit channel without widening output (json=%s)",
async (json) => {
await fs.writeFile(
logPath,
logLine({ module: "gateway/channels/slack/send", message: "unrelated message" }),
);
const error = await channelsLogsCommand({ channel: "slakc", json }, runtime).catch(
(cause: unknown) => cause,
);
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toContain('Unknown channel "slakc". Valid channels: all,');
expect((error as Error).message).toContain("external-chat");
expect((error as Error).message).toContain("slack");
expect(runtime.log).not.toHaveBeenCalled();
},
);
it("redacts credential-bearing channel lines in text output", async () => {
const fixtureCredential = "opaque-registry-value-1234567890";
registerSecretValueForRedaction(fixtureCredential);
await fs.writeFile(
logPath,
logLine({
module: "gateway/channels/slack/send",
message: `opaque=${fixtureCredential}`,
}),
);
await channelsLogsCommand({ channel: "slack" }, runtime);
const output = runtime.log.mock.calls.flat().join("\n");
expect(output).toContain("2026-04-25T12:00:00.000Z info");
expect(output).toContain("opaque=opaque…7890");
expect(output).not.toContain(fixtureCredential);
});
it("redacts credential-bearing channel lines in JSON output", async () => {
const fixtureCredential = "opaque-registry-value-1234567890";
registerSecretValueForRedaction(fixtureCredential);
await fs.writeFile(
logPath,
logLine({
module: "gateway/channels/slack/send",
message: `opaque=${fixtureCredential}`,
}),
);
await channelsLogsCommand({ channel: "slack", json: true }, runtime);
const payload = readJsonPayload();
expect(payload.lines[0]?.message).toBe("opaque=opaque…7890");
expect(JSON.stringify(payload)).not.toContain(fixtureCredential);
});
it("preserves ordering and line limits for an explicit all filter", async () => {
await fs.writeFile(
logPath,
[
logLine({ module: "gateway/channels/slack/send", message: "first" }),
logLine({ module: "gateway/channels/external-chat/send", message: "second" }),
logLine({ module: "gateway/channels/slack/send", message: "third" }),
].join("\n"),
);
await channelsLogsCommand({ channel: "all", lines: 2, json: true }, runtime);
const payload = readJsonPayload();
expect(payload.channel).toBe("all");
expect(payload.lines.map((line) => line.message)).toEqual(["second", "third"]);
});
it("finds sparse channel records beyond the shared 5000-line cap", async () => {
const filler = logLine({ module: "gateway/health", message: "ok" });
const lines = [
logLine({ module: "gateway/channels/slack/send", message: "first match" }),
...Array.from({ length: 5000 }, () => filler),
logLine({ module: "gateway/channels/slack/send", message: "second match" }),
];
await fs.writeFile(logPath, lines.join("\n"));
await channelsLogsCommand({ channel: "slack", lines: 2000, json: true }, runtime);
expect(readJsonPayload().lines.map((line) => line.message)).toEqual([
"first match",
"second match",
]);
});
it("treats an omitted channel filter as all", async () => {
await fs.writeFile(
logPath,
logLine({ module: "gateway/channels/slack/send", message: "omitted filter" }),
);
await channelsLogsCommand({ json: true }, runtime);
const payload = readJsonPayload();
expect(payload.channel).toBe("all");
expect(payload.lines.map((line) => line.message)).toEqual(["omitted filter"]);
});
it("falls back to the latest rolling log when the configured rolling file is missing", async () => {
const configuredFile = path.join(tempDir, "openclaw-2026-04-26.log");
const fallbackFile = path.join(tempDir, "openclaw-2026-04-25.log");
@@ -149,92 +283,6 @@ describe("channelsLogsCommand", () => {
expect(payload.lines.map((line) => line.message)).toEqual(["current sent"]);
});
it("fills short positional reads before parsing channel log lines", async () => {
const realOpen = fs.open.bind(fs);
const readLengths: number[] = [];
vi.spyOn(fs, "open").mockImplementation(async (...args) => {
const handle = await realOpen(...args);
const realRead = handle.read.bind(handle) as PositionalRead;
const shortRead = vi.fn<PositionalRead>((buffer, offset, length, position) => {
readLengths.push(length);
return realRead(buffer, offset, Math.min(length, 4), position);
});
Object.defineProperty(handle, "read", { configurable: true, value: shortRead });
return handle;
});
await fs.writeFile(
logPath,
[
logLine({ module: "gateway/channels/slack/send", message: "first" }),
logLine({ module: "gateway/channels/slack/send", message: "second" }),
].join("\n"),
);
await channelsLogsCommand({ channel: "slack", json: true }, runtime);
expect(readJsonPayload().lines.map((line) => line.message)).toEqual(["first", "second"]);
expect(readLengths.length).toBeGreaterThan(1);
});
it("returns the first line of the tail window when start aligns with a line boundary", async () => {
// MAX_BYTES in readTailLines is 1_000_000. We build a file of 2_000_000 bytes
// made of 10_000 lines each exactly 200 bytes (199 payload + "\n"), so the
// read window starts at byte offset 1_000_000 which is exactly on a line
// boundary (byte 999_999 is the trailing "\n" of the previous line).
// Without checking the byte before the window, readTailLines drops line 5000 silently.
const LINE_SIZE = 200;
const TOTAL_LINES = 10_000;
const FIRST_INDEX = 5000; // first line of the tail window after alignment
const buildLine = (message: string) => {
const base = logLine({
module: "gateway/channels/slack/send",
message,
});
const payloadLen = LINE_SIZE - 1; // reserve 1 byte for newline
// Re-emit with a padded message so total byte length is constant.
const padNeeded = payloadLen - Buffer.byteLength(base);
if (padNeeded < 0) {
throw new Error(`base log line too long: ${Buffer.byteLength(base)} > ${payloadLen}`);
}
const padded = logLine({
module: "gateway/channels/slack/send",
message: message + " ".repeat(padNeeded),
});
if (Buffer.byteLength(padded) !== payloadLen) {
throw new Error(`padded line wrong size: ${Buffer.byteLength(padded)} vs ${payloadLen}`);
}
return padded + "\n";
};
const handle = await fs.open(logPath, "w");
try {
for (let i = 0; i < TOTAL_LINES; i++) {
let message: string;
if (i === FIRST_INDEX) {
message = "first-line-in-window";
} else if (i === TOTAL_LINES - 1) {
message = "last-line";
} else {
message = "filler";
}
await handle.write(buildLine(message));
}
} finally {
await handle.close();
}
await channelsLogsCommand(
{ channel: "slack", json: true, lines: String(TOTAL_LINES) },
runtime,
);
const payload = readJsonPayload();
const messages = payload.lines.map((line) => line.message.trimEnd());
expect(messages[0]).toBe("first-line-in-window");
expect(messages[messages.length - 1]).toBe("last-line");
});
it("does not fall back to rolling logs for a missing custom log file", async () => {
const configuredFile = path.join(tempDir, "custom-channel.log");
const fallbackFile = path.join(tempDir, "openclaw-2026-04-25.log");
+61 -77
View File
@@ -1,14 +1,14 @@
// Implements channel-scoped tailing of the OpenClaw log file.
import fs from "node:fs/promises";
import { parseStrictPositiveInteger } from "@openclaw/normalization-core/number-coercion";
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import { theme } from "../../../packages/terminal-core/src/theme.js";
import { normalizeChatChannelId as normalizeBundledChannelId } from "../../channels/registry.js";
import { readFileWindowFully } from "../../infra/file-read.js";
import { getResolvedLoggerSettings } from "../../logging.js";
import { resolveLogFile } from "../../logging/log-tail.js";
import { parseLogLine } from "../../logging/parse-log-line.js";
import { listManifestChannelContributionIds } from "../../plugins/manifest-contribution-ids.js";
import {
CHAT_CHANNEL_ORDER,
normalizeChatChannelId as normalizeBundledChannelId,
} from "../../channels/registry.js";
import { readConfiguredParsedLogTail } from "../../logging/log-tail.js";
import type { ParsedLogLine } from "../../logging/parse-log-line.js";
import { loadPluginManifestRegistryForPluginRegistry } from "../../plugins/plugin-registry.js";
import { defaultRuntime, type RuntimeEnv, writeRuntimeJson } from "../../runtime.js";
export type ChannelsLogsOptions = {
@@ -17,44 +17,66 @@ export type ChannelsLogsOptions = {
json?: boolean;
};
type LogLine = ReturnType<typeof parseLogLine>;
const DEFAULT_LIMIT = 200;
const MAX_BYTES = 1_000_000;
function listManifestChannelIds(): Set<string> {
return new Set(
listManifestChannelContributionIds({
includeDisabled: true,
env: process.env,
type ChannelLogFilter = { channel: string; pluginIds: ReadonlySet<string> };
type ManifestChannel = { id: string; pluginId: string };
function listManifestChannels(): ManifestChannel[] {
return loadPluginManifestRegistryForPluginRegistry({
includeDisabled: true,
env: process.env,
}).plugins.flatMap((plugin) =>
plugin.channels.flatMap((rawChannel) => {
const id = normalizeLowercaseStringOrEmpty(rawChannel);
return id ? [{ id, pluginId: plugin.id }] : [];
}),
);
}
function parseChannelFilter(raw?: string) {
function parseChannelFilter(raw?: string): ChannelLogFilter {
if (raw === undefined) {
return { channel: "all", pluginIds: new Set() };
}
const trimmed = normalizeLowercaseStringOrEmpty(raw);
if (!trimmed || trimmed === "all") {
return "all";
if (trimmed === "all") {
return { channel: "all", pluginIds: new Set() };
}
const manifestChannels = listManifestChannels();
const bundled = normalizeBundledChannelId(trimmed);
if (bundled) {
return bundled;
const channel = bundled ?? trimmed;
const pluginIds = new Set(
manifestChannels.filter((entry) => entry.id === channel).map((entry) => entry.pluginId),
);
if (bundled || pluginIds.size > 0) {
return { channel, pluginIds };
}
return listManifestChannelIds().has(trimmed) ? trimmed : "all";
const manifestIds = [...new Set(manifestChannels.map((entry) => entry.id))].toSorted();
const validChannels = ["all", ...new Set([...CHAT_CHANNEL_ORDER, ...manifestIds])];
throw new Error(
`Unknown channel ${JSON.stringify(raw)}. Valid channels: ${validChannels.join(", ")}`,
);
}
function matchesChannel(line: NonNullable<LogLine>, channel: string) {
function matchesChannelContext(value: string | undefined, channel: string) {
const path = `gateway/channels/${channel}`;
return value === channel || value === path || value?.startsWith(`${path}/`) === true;
}
function matchesChannel(
line: Pick<ParsedLogLine, "subsystem" | "module" | "plugin">,
filter: ChannelLogFilter,
) {
const { channel } = filter;
if (channel === "all") {
return true;
}
const needle = `gateway/channels/${channel}`;
if (line.subsystem?.includes(needle)) {
return true;
}
if (line.module?.includes(channel)) {
return true;
}
return false;
return (
matchesChannelContext(line.subsystem, channel) ||
matchesChannelContext(line.module, channel) ||
(line.plugin !== undefined && filter.pluginIds.has(line.plugin))
);
}
function parseLinesOption(value: unknown): number {
@@ -68,66 +90,28 @@ function parseLinesOption(value: unknown): number {
return parsed;
}
async function readTailLines(file: string, limit: number): Promise<string[]> {
const stat = await fs.stat(file).catch(() => null);
if (!stat) {
return [];
}
const size = stat.size;
const start = Math.max(0, size - MAX_BYTES);
const handle = await fs.open(file, "r");
try {
let prefix = "";
if (start > 0) {
const prefixBuf = Buffer.alloc(1);
const prefixRead = await handle.read(prefixBuf, 0, 1, start - 1);
prefix = prefixBuf.toString("utf8", 0, prefixRead.bytesRead);
}
const length = Math.max(0, size - start);
if (length === 0) {
return [];
}
const buffer = Buffer.alloc(length);
const bytesRead = await readFileWindowFully(handle, buffer, start);
const text = buffer.toString("utf8", 0, bytesRead);
let lines = text.split("\n");
if (start > 0 && prefix !== "\n") {
lines = lines.slice(1);
}
if (lines.length && lines[lines.length - 1] === "") {
lines = lines.slice(0, -1);
}
if (lines.length > limit) {
lines = lines.slice(lines.length - limit);
}
return lines;
} finally {
await handle.close();
}
}
/** Print or serialize recent log lines matching one channel subsystem/module. */
export async function channelsLogsCommand(
opts: ChannelsLogsOptions,
runtime: RuntimeEnv = defaultRuntime,
) {
const channel = parseChannelFilter(opts.channel);
const filter = parseChannelFilter(opts.channel);
const { channel } = filter;
const limit = parseLinesOption(opts.lines);
const file = await resolveLogFile(getResolvedLoggerSettings().file);
const rawLines = await readTailLines(file, limit * 4);
const parsed = rawLines
.map(parseLogLine)
.filter((line): line is NonNullable<LogLine> => Boolean(line));
const filtered = parsed.filter((line) => matchesChannel(line, channel));
const lines = filtered.slice(Math.max(0, filtered.length - limit));
const tail = await readConfiguredParsedLogTail({
limit,
maxBytes: MAX_BYTES,
filter: (line) => matchesChannel(line, filter),
});
const lines = tail.lines;
if (opts.json) {
writeRuntimeJson(runtime, { file, channel, lines });
writeRuntimeJson(runtime, { file: tail.file, channel, lines });
return;
}
runtime.log(theme.info(`Log file: ${file}`));
runtime.log(theme.info(`Log file: ${tail.file}`));
if (channel !== "all") {
runtime.log(theme.info(`Channel: ${channel}`));
}
+29 -8
View File
@@ -105,19 +105,40 @@ describe("readConfiguredLogTail", () => {
});
});
it("keeps the first line when the byte window starts exactly after a newline", async () => {
const { readConfiguredLogTail } = await import("./log-tail.js");
const tempDir = tempDirs.make("openclaw-log-tail-");
const file = path.join(tempDir, "openclaw-2026-01-22.log");
const line = (message: string) => `${message}${" ".repeat(199 - message.length)}\n`;
const content = Array.from({ length: 10_000 }, (_, index) =>
line(index === 5000 ? "first-line-in-window" : "filler"),
).join("");
await fs.writeFile(file, content);
setLoggerOverride({ file });
const result = await readConfiguredLogTail({ limit: 5000, maxBytes: 1_000_000 });
expect(result.lines).toHaveLength(5000);
expect(result.lines[0]?.trimEnd()).toBe("first-line-in-window");
});
it("falls back only within the active profile's rolling log family", async () => {
const tempDir = tempDirs.make("openclaw-log-tail-");
const missing = path.join(tempDir, "openclaw-dev-2026-01-22.log");
const devLog = path.join(tempDir, "openclaw-dev-2026-01-21.log");
const missing = path.join(tempDir, "openclaw-2026-01-22.log");
const defaultLog = path.join(tempDir, "openclaw-2026-01-21.log");
await fs.writeFile(devLog, "dev profile\n");
const devLog = path.join(tempDir, "openclaw-dev-2026-01-21.log");
await fs.writeFile(defaultLog, "default profile\n");
await fs.utimes(devLog, new Date(0), new Date(0));
await fs.utimes(defaultLog, new Date(), new Date());
const { resolveLogFile } = await import("./log-tail.js");
const result = await resolveLogFile(missing, { rolling: true });
await fs.writeFile(devLog, "dev profile\n");
await fs.utimes(defaultLog, new Date(0), new Date(0));
await fs.utimes(devLog, new Date(), new Date());
setLoggerOverride({ file: missing });
expect(result).toBe(devLog);
const { readConfiguredLogTail } = await import("./log-tail.js");
const result = await readConfiguredLogTail();
expect(result.file).toBe(defaultLog);
expect(result.lines).toEqual(["default profile"]);
});
it("does not reinterpret an explicit profile-shaped logging.file as rolling", async () => {
+37 -9
View File
@@ -6,6 +6,7 @@ import { clamp } from "../utils.js";
import { isRollingLogFilePath, isSameRollingLogFileFamily } from "./log-file-path.js";
import "./logger.js";
import { getResolvedLoggerFileTarget } from "./logger-settings-internal.js";
import { parseLogLine, type ParsedLogLine } from "./parse-log-line.js";
import { redactSensitiveLines, resolveRedactOptions } from "./redact.js";
// Tail reader for the active log file, with cursor reset and line redaction.
@@ -24,11 +25,13 @@ export type LogTailPayload = {
reset: boolean;
};
/** Redacted configured log tail with only parseable structured records. */
type ParsedLogTailPayload = Omit<LogTailPayload, "lines"> & {
lines: ParsedLogLine[];
};
/** Resolves a rolling daily log path to the newest existing rolling log when needed. */
export async function resolveLogFile(
file: string,
options?: { rolling?: boolean },
): Promise<string> {
async function resolveLogFile(file: string, options?: { rolling?: boolean }): Promise<string> {
const stat = await fs.stat(file).catch(() => null);
if (stat) {
return file;
@@ -63,6 +66,7 @@ async function readLogSlice(params: {
cursor?: number;
limit: number;
maxBytes: number;
filter?: (line: string) => boolean;
}): Promise<Omit<LogTailPayload, "file">> {
const stat = await fs.stat(params.file).catch(() => null);
if (!stat) {
@@ -137,6 +141,10 @@ async function readLogSlice(params: {
if (lines.length > 0 && lines[lines.length - 1] === "") {
lines = lines.slice(0, -1);
}
if (params.filter) {
// Sparse consumers inspect the full byte-bounded window before the shared line cap.
lines = lines.filter(params.filter);
}
if (lines.length > limit) {
truncated = true;
lines = lines.slice(lines.length - limit);
@@ -157,11 +165,10 @@ async function readLogSlice(params: {
}
/** Reads and redacts the configured log tail with bounded bytes and line count. */
export async function readConfiguredLogTail(params?: {
cursor?: number;
limit?: number;
maxBytes?: number;
}): Promise<LogTailPayload> {
export async function readConfiguredLogTail(
params?: { cursor?: number; limit?: number; maxBytes?: number },
filter?: (line: string) => boolean,
): Promise<LogTailPayload> {
const target = getResolvedLoggerFileTarget();
const file = await resolveLogFile(target.file, { rolling: target.rolling });
const result = await readLogSlice({
@@ -169,6 +176,7 @@ export async function readConfiguredLogTail(params?: {
cursor: params?.cursor,
limit: params?.limit ?? DEFAULT_LIMIT,
maxBytes: params?.maxBytes ?? DEFAULT_MAX_BYTES,
filter,
});
const redaction = resolveRedactOptions();
return {
@@ -177,3 +185,23 @@ export async function readConfiguredLogTail(params?: {
lines: redactSensitiveLines(result.lines, redaction),
};
}
/** Reads the canonical configured tail and parses its already-redacted lines. */
export async function readConfiguredParsedLogTail(params?: {
cursor?: number;
limit?: number;
maxBytes?: number;
filter?: (line: Pick<ParsedLogLine, "subsystem" | "module" | "plugin">) => boolean;
}): Promise<ParsedLogTailPayload> {
const tail = await readConfiguredLogTail(params, (raw) => {
const parsed = parseLogLine(raw);
return parsed !== null && (params?.filter?.(parsed) ?? true);
});
return {
...tail,
lines: tail.lines.flatMap((line) => {
const parsed = parseLogLine(line);
return parsed ? [parsed] : [];
}),
};
}
+13
View File
@@ -56,6 +56,19 @@ describe("parseLogLine", () => {
expect(parseLogLine(JSON.stringify({ 0: "worker", 1: "ready" }))?.subsystem).toBeUndefined();
});
it("retains the exact plugin identity from logger binding metadata", () => {
const parsed = parseLogLine(
JSON.stringify({
0: '{"subsystem":"gateway/channels/external-chat"}',
1: "sent",
_meta: { name: '{"plugin":"vendor-external-chat","feature":"delivery"}' },
}),
);
expect(parsed?.plugin).toBe("vendor-external-chat");
expect(parsed?.subsystem).toBe("gateway/channels/external-chat");
});
it("falls back to meta timestamp when top-level time is missing", () => {
const line = JSON.stringify({
0: "hello",
+13 -7
View File
@@ -3,14 +3,16 @@ import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce";
// Parser for JSON LogTape lines emitted by the OpenClaw logger.
type ParsedLogLine = {
export type ParsedLogLine = {
time?: string;
level?: string;
subsystem?: string;
module?: string;
plugin?: string;
message: string;
raw: string;
};
type LogContext = Pick<ParsedLogLine, "subsystem" | "module" | "plugin">;
function extractMessage(value: Record<string, unknown>): string {
const parts: string[] = [];
@@ -28,7 +30,7 @@ function extractMessage(value: Record<string, unknown>): string {
return parts.join(" ");
}
function parseMetaName(raw?: unknown): { subsystem?: string; module?: string } {
function parseMetaName(raw?: unknown): LogContext {
if (typeof raw !== "string") {
return {};
}
@@ -37,6 +39,7 @@ function parseMetaName(raw?: unknown): { subsystem?: string; module?: string } {
return {
subsystem: typeof parsed.subsystem === "string" ? parsed.subsystem : undefined,
module: typeof parsed.module === "string" ? parsed.module : undefined,
plugin: typeof parsed.plugin === "string" ? parsed.plugin : undefined,
};
} catch {
return {};
@@ -46,12 +49,14 @@ function parseMetaName(raw?: unknown): { subsystem?: string; module?: string } {
function resolveContext(
value: Record<string, unknown>,
meta: Record<string, unknown> | undefined,
): { subsystem?: string; module?: string } {
): LogContext {
const metadataContext = parseMetaName(meta?.name);
if (metadataContext.subsystem || metadataContext.module) {
return metadataContext;
}
return parseMetaName(value["0"]);
const positionalContext = parseMetaName(value["0"]);
return {
subsystem: metadataContext.subsystem ?? positionalContext.subsystem,
module: metadataContext.module ?? positionalContext.module,
plugin: metadataContext.plugin ?? positionalContext.plugin,
};
}
/** Parses a raw log line into compact metadata and message text, or null for non-JSON lines. */
@@ -74,6 +79,7 @@ export function parseLogLine(raw: string): ParsedLogLine | null {
level: normalizeOptionalLowercaseString(levelRaw),
subsystem: context.subsystem,
module: context.module,
plugin: context.plugin,
message: typeof parsed.message === "string" ? parsed.message : extractMessage(parsed),
raw,
};
+10 -5
View File
@@ -1975,9 +1975,11 @@ describe("redactSensitiveLines", () => {
expect(result[1]).toBe("normal log line");
});
it("returns lines unmodified when mode is off", () => {
it("returns lines unmodified when redaction is off", () => {
const resolved = resolveRedactOptions({ mode: "off", patterns: defaults });
const lines = ["TOKEN=abcdef1234567890ghij"];
const secret = "opaque-registry-value-1234567890";
registerSecretValueForRedaction(secret);
const lines = [`TOKEN=abcdef1234567890ghij ${secret}`];
expect(redactSensitiveLines(lines, resolved)).toEqual(lines);
});
@@ -2008,12 +2010,15 @@ describe("redactSensitiveLines", () => {
).toEqual(["Authorization: Digest", " ***; status=401"]);
});
it("returns lines unmodified when resolved patterns is empty — does not fall back to defaults", () => {
it("applies exact registered secrets without falling back from empty resolved patterns", () => {
// Simulates the case where all user-configured patterns fail to compile.
// The pre-resolved empty array must be honored, not silently replaced with defaults.
const resolved = { mode: "tools" as const, patterns: [], redactFormBodies: false };
const lines = ["TOKEN=abcdef1234567890ghij"];
expect(redactSensitiveLines(lines, resolved)).toEqual(lines);
const secret = "opaque-registry-value-1234567890";
registerSecretValueForRedaction(secret);
expect(redactSensitiveLines([`TOKEN=abcdef1234567890ghij ${secret}`], resolved)).toEqual([
"TOKEN=abcdef1234567890ghij opaque…7890",
]);
});
it("returns empty array unchanged — does not produce a synthetic blank line", () => {
+7 -3
View File
@@ -1108,12 +1108,16 @@ export function getDefaultRedactPatterns(): string[] {
// line boundaries, then split back. Use this instead of mapping redactSensitiveText when
// options are resolved once per request.
export function redactSensitiveLines(lines: string[], resolved: ResolvedRedactOptions): string[] {
if (resolved.mode === "off" || !resolved.patterns.length || lines.length === 0) {
if (lines.length === 0 || resolved.mode === "off") {
return lines;
}
const exactRedactedLines = lines.map((line) => redactRegisteredSecretValues(line, maskToken));
if (!resolved.patterns.length) {
return exactRedactedLines;
}
const redactedLines = resolved.redactFormBodies
? lines.map((line) => redactFormBody(redactUrlQueryPairs(line)))
: lines;
? exactRedactedLines.map((line) => redactFormBody(redactUrlQueryPairs(line)))
: exactRedactedLines;
let redacted = redactedLines.join("\n");
if (resolved.redactStructuredAuthHeaders) {
redacted = redactStructuredAuthHeaders(redacted, "***");