openclaw-db9-audit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,166 @@
1
+ import type { AgentEventPayload, PluginLogger } from "openclaw/plugin-sdk/core";
2
+ import { Db9AuditRunTracker } from "./run-tracker.js";
3
+ import { redactUnknown } from "./redact.js";
4
+ import type { Db9AuditDiagnosticEvent, Db9AuditRedactConfig } from "./types.js";
5
+ import { joinPosixPath, normalizeLogRoot, resolveAgentIdFromSessionKey, toDatePartition } from "./utils.js";
6
+
7
+ export type EventLogAppender = {
8
+ appendText: (path: string, text: string) => Promise<void>;
9
+ };
10
+
11
+ const MAX_BUFFERED_LINES = 10_000;
12
+
13
+ function normalizeStreamName(stream: string): string {
14
+ const trimmed = stream.trim().toLowerCase();
15
+ if (!trimmed) {
16
+ return "misc";
17
+ }
18
+ if (trimmed === "lifecycle" || trimmed === "tool" || trimmed === "assistant" || trimmed === "error") {
19
+ return trimmed;
20
+ }
21
+ return "misc";
22
+ }
23
+
24
+ export function resolveEventLogPath(params: {
25
+ logRoot: string;
26
+ stream: string;
27
+ agentId: string;
28
+ timestampMs: number;
29
+ }): string {
30
+ return joinPosixPath(
31
+ normalizeLogRoot(params.logRoot),
32
+ normalizeStreamName(params.stream),
33
+ toDatePartition(params.timestampMs),
34
+ `${params.agentId}.jsonl`,
35
+ );
36
+ }
37
+
38
+ export class Db9AuditEventLogSync {
39
+ private readonly getAppender: () => Promise<EventLogAppender | null>;
40
+ private readonly logger?: PluginLogger | undefined;
41
+ private readonly logRoot: string;
42
+ private readonly batchSize: number;
43
+ private readonly redactConfig: Db9AuditRedactConfig;
44
+ private readonly runTracker: Db9AuditRunTracker;
45
+ private readonly pending = new Map<string, string[]>();
46
+ private flushPromise: Promise<{ flushedFiles: number; flushedLines: number }> | null = null;
47
+
48
+ constructor(params: {
49
+ getAppender: () => Promise<EventLogAppender | null>;
50
+ logger?: PluginLogger | undefined;
51
+ logRoot: string;
52
+ batchSize: number;
53
+ redactConfig: Db9AuditRedactConfig;
54
+ runTracker: Db9AuditRunTracker;
55
+ }) {
56
+ this.getAppender = params.getAppender;
57
+ this.logger = params.logger;
58
+ this.logRoot = params.logRoot;
59
+ this.batchSize = params.batchSize;
60
+ this.redactConfig = params.redactConfig;
61
+ this.runTracker = params.runTracker;
62
+ }
63
+
64
+ enqueue(event: AgentEventPayload): void {
65
+ const sessionKey = event.sessionKey?.trim() || undefined;
66
+ const agentId = this.runTracker.resolveAgentIdForRun(event.runId, sessionKey);
67
+ const path = resolveEventLogPath({
68
+ logRoot: this.logRoot,
69
+ stream: event.stream,
70
+ agentId,
71
+ timestampMs: event.ts,
72
+ });
73
+ const line = JSON.stringify({
74
+ runId: event.runId,
75
+ seq: event.seq,
76
+ stream: event.stream,
77
+ ts: event.ts,
78
+ agentId,
79
+ ...(sessionKey ? { sessionKey } : {}),
80
+ data: redactUnknown(event.data, this.redactConfig),
81
+ });
82
+ this.pushLine(path, line);
83
+ }
84
+
85
+ enqueueDiagnostic(event: Db9AuditDiagnosticEvent): void {
86
+ const sessionKey = event.sessionKey?.trim() || undefined;
87
+ const agentId =
88
+ event.agentId?.trim() ||
89
+ resolveAgentIdFromSessionKey(sessionKey) ||
90
+ "db9-audit";
91
+ const timestampMs = Date.now();
92
+ const path = resolveEventLogPath({
93
+ logRoot: this.logRoot,
94
+ stream: event.stream ?? "error",
95
+ agentId,
96
+ timestampMs,
97
+ });
98
+ const line = JSON.stringify({
99
+ runId: event.runId,
100
+ ts: timestampMs,
101
+ stream: event.stream ?? "error",
102
+ agentId,
103
+ ...(sessionKey ? { sessionKey } : {}),
104
+ data: redactUnknown(event.data, this.redactConfig),
105
+ });
106
+ this.pushLine(path, line);
107
+ }
108
+
109
+ private pushLine(path: string, line: string): void {
110
+ const bucket = this.pending.get(path) ?? [];
111
+ bucket.push(`${line}\n`);
112
+ if (bucket.length > MAX_BUFFERED_LINES) {
113
+ bucket.splice(0, bucket.length - MAX_BUFFERED_LINES);
114
+ this.logger?.warn(`db9-audit: dropping old buffered FS log lines for ${path}`);
115
+ }
116
+ this.pending.set(path, bucket);
117
+ }
118
+
119
+ async flushPending(): Promise<{ flushedFiles: number; flushedLines: number }> {
120
+ if (this.flushPromise) {
121
+ return await this.flushPromise;
122
+ }
123
+ this.flushPromise = this.doFlushPending();
124
+ try {
125
+ return await this.flushPromise;
126
+ } finally {
127
+ this.flushPromise = null;
128
+ }
129
+ }
130
+
131
+ private async doFlushPending(): Promise<{ flushedFiles: number; flushedLines: number }> {
132
+ const appender = await this.getAppender();
133
+ if (!appender || this.pending.size === 0) {
134
+ return { flushedFiles: 0, flushedLines: 0 };
135
+ }
136
+
137
+ let flushedFiles = 0;
138
+ let flushedLines = 0;
139
+ const buckets = [...this.pending.entries()];
140
+ for (const [path, lines] of buckets) {
141
+ if (lines.length === 0) {
142
+ this.pending.delete(path);
143
+ continue;
144
+ }
145
+ const toFlush = lines.splice(0, this.batchSize);
146
+ if (toFlush.length === 0) {
147
+ continue;
148
+ }
149
+ try {
150
+ await appender.appendText(path, toFlush.join(""));
151
+ flushedFiles += 1;
152
+ flushedLines += toFlush.length;
153
+ } catch (error) {
154
+ lines.unshift(...toFlush);
155
+ this.logger?.warn(`db9-audit: FS append failed for ${path}: ${String(error)}`);
156
+ }
157
+ if (lines.length === 0) {
158
+ this.pending.delete(path);
159
+ } else {
160
+ this.pending.set(path, lines);
161
+ }
162
+ }
163
+
164
+ return { flushedFiles, flushedLines };
165
+ }
166
+ }
@@ -0,0 +1,317 @@
1
+ import type { PluginLogger } from "openclaw/plugin-sdk/core";
2
+ import WebSocket from "ws";
3
+ import { Buffer } from "node:buffer";
4
+ import { dirnamePosix, extractErrorMessage } from "./utils.js";
5
+
6
+ type FsResponseEnvelope = {
7
+ id?: string;
8
+ ok?: boolean;
9
+ data?: unknown;
10
+ error?: {
11
+ code?: string;
12
+ message?: string;
13
+ };
14
+ };
15
+
16
+ export class Db9FsClientError extends Error {
17
+ readonly code?: string | undefined;
18
+ readonly retriable: boolean;
19
+
20
+ constructor(params: {
21
+ message: string;
22
+ code?: string | undefined;
23
+ retriable: boolean;
24
+ cause?: unknown;
25
+ }) {
26
+ super(params.message, params.cause !== undefined ? { cause: params.cause } : undefined);
27
+ this.name = "Db9FsClientError";
28
+ this.code = params.code;
29
+ this.retriable = params.retriable;
30
+ }
31
+ }
32
+
33
+ function isOpen(socket: WebSocket | null): socket is WebSocket {
34
+ return socket !== null && socket.readyState === WebSocket.OPEN;
35
+ }
36
+
37
+ export class Db9FsClient {
38
+ readonly wsUrl: string;
39
+ readonly username: string;
40
+ readonly password: string;
41
+ private readonly logger?: PluginLogger | undefined;
42
+ private readonly requestTimeoutMs: number;
43
+ private socket: WebSocket | null = null;
44
+ private connectPromise: Promise<void> | null = null;
45
+ private requestChain: Promise<void> = Promise.resolve();
46
+ private requestId = 0;
47
+ private authenticated = false;
48
+ private readonly knownDirs = new Set<string>();
49
+
50
+ constructor(params: {
51
+ wsUrl: string;
52
+ username: string;
53
+ password: string;
54
+ logger?: PluginLogger | undefined;
55
+ requestTimeoutMs?: number | undefined;
56
+ }) {
57
+ this.wsUrl = params.wsUrl;
58
+ this.username = params.username;
59
+ this.password = params.password;
60
+ this.logger = params.logger;
61
+ this.requestTimeoutMs = params.requestTimeoutMs ?? 10_000;
62
+ }
63
+
64
+ async connect(): Promise<void> {
65
+ if (this.authenticated && isOpen(this.socket)) {
66
+ return;
67
+ }
68
+ if (this.connectPromise) {
69
+ return await this.connectPromise;
70
+ }
71
+ this.connectPromise = this.openAndAuthenticate();
72
+ try {
73
+ await this.connectPromise;
74
+ } finally {
75
+ this.connectPromise = null;
76
+ }
77
+ }
78
+
79
+ private async openAndAuthenticate(): Promise<void> {
80
+ await this.close();
81
+ const socket = new WebSocket(this.wsUrl);
82
+ this.socket = socket;
83
+ this.authenticated = false;
84
+
85
+ await new Promise<void>((resolve, reject) => {
86
+ const onOpen = () => {
87
+ cleanup();
88
+ resolve();
89
+ };
90
+ const onError = (error: Error) => {
91
+ cleanup();
92
+ reject(
93
+ new Db9FsClientError({
94
+ message: `FS WebSocket connection failed: ${error.message}`,
95
+ retriable: true,
96
+ cause: error,
97
+ }),
98
+ );
99
+ };
100
+ const onClose = () => {
101
+ cleanup();
102
+ reject(
103
+ new Db9FsClientError({
104
+ message: "FS WebSocket closed before opening completed",
105
+ retriable: true,
106
+ }),
107
+ );
108
+ };
109
+ const cleanup = () => {
110
+ socket.off("open", onOpen);
111
+ socket.off("error", onError);
112
+ socket.off("close", onClose);
113
+ };
114
+
115
+ socket.once("open", onOpen);
116
+ socket.once("error", onError);
117
+ socket.once("close", onClose);
118
+ });
119
+
120
+ socket.on("close", () => {
121
+ this.authenticated = false;
122
+ this.socket = null;
123
+ this.logger?.warn(`db9-audit: FS WebSocket closed: ${this.wsUrl}`);
124
+ });
125
+ socket.on("error", (error) => {
126
+ this.logger?.warn(`db9-audit: FS WebSocket error: ${extractErrorMessage(error) ?? String(error)}`);
127
+ });
128
+
129
+ await this.sendOnSocket(socket, "auth", {
130
+ username: this.username,
131
+ password: this.password,
132
+ });
133
+ this.authenticated = true;
134
+ this.logger?.debug?.(`db9-audit: FS authenticated as ${this.username}`);
135
+ }
136
+
137
+ async stat(pathValue: string): Promise<Record<string, unknown> | null> {
138
+ try {
139
+ const data = await this.sendRequest("stat", { path: pathValue.trim() });
140
+ return data && typeof data === "object" ? (data as Record<string, unknown>) : {};
141
+ } catch (error) {
142
+ if (error instanceof Db9FsClientError && error.code === "ENOENT") {
143
+ return null;
144
+ }
145
+ throw error;
146
+ }
147
+ }
148
+
149
+ async mkdirp(pathValue: string): Promise<void> {
150
+ const normalized = pathValue.trim();
151
+ if (!normalized || normalized === "/" || this.knownDirs.has(normalized)) {
152
+ return;
153
+ }
154
+ await this.sendRequest("mkdir", {
155
+ path: normalized,
156
+ recursive: true,
157
+ });
158
+ this.knownDirs.add(normalized);
159
+ }
160
+
161
+ async appendText(pathValue: string, text: string): Promise<void> {
162
+ const normalized = pathValue.trim();
163
+ if (!normalized) {
164
+ throw new Error("FS append path must not be empty");
165
+ }
166
+ await this.mkdirp(dirnamePosix(normalized));
167
+ try {
168
+ await this.sendRequest("append", {
169
+ path: normalized,
170
+ content: Buffer.from(text, "utf8").toString("base64"),
171
+ encoding: "base64",
172
+ });
173
+ } catch (error) {
174
+ if (error instanceof Db9FsClientError && error.code === "ENOENT") {
175
+ await this.mkdirp(dirnamePosix(normalized));
176
+ await this.sendRequest("append", {
177
+ path: normalized,
178
+ content: Buffer.from(text, "utf8").toString("base64"),
179
+ encoding: "base64",
180
+ });
181
+ return;
182
+ }
183
+ throw error;
184
+ }
185
+ }
186
+
187
+ private async sendRequest(op: string, payload: Record<string, unknown>): Promise<unknown> {
188
+ return await this.enqueue(async () => {
189
+ await this.connect();
190
+ const socket = this.socket;
191
+ if (!isOpen(socket)) {
192
+ throw new Db9FsClientError({
193
+ message: "FS WebSocket is not connected",
194
+ retriable: true,
195
+ });
196
+ }
197
+
198
+ return await this.sendOnSocket(socket, op, payload);
199
+ });
200
+ }
201
+
202
+ private async sendOnSocket(socket: WebSocket, op: string, payload: Record<string, unknown>): Promise<unknown> {
203
+ const id = String(++this.requestId);
204
+ const frame = JSON.stringify({ id, op, ...payload });
205
+ return await this.awaitResponse(socket, id, frame);
206
+ }
207
+
208
+ private async awaitResponse(socket: WebSocket, id: string, frame: string): Promise<unknown> {
209
+ return await new Promise<unknown>((resolve, reject) => {
210
+ const timeout = setTimeout(() => {
211
+ cleanup();
212
+ reject(
213
+ new Db9FsClientError({
214
+ message: `FS request timed out: ${id}`,
215
+ retriable: true,
216
+ }),
217
+ );
218
+ }, this.requestTimeoutMs);
219
+
220
+ const onMessage = (message: WebSocket.RawData) => {
221
+ cleanup();
222
+ try {
223
+ const parsed = JSON.parse(message.toString("utf8")) as FsResponseEnvelope;
224
+ if (parsed.ok !== true) {
225
+ reject(
226
+ new Db9FsClientError({
227
+ code: parsed.error?.code,
228
+ message: parsed.error?.message ?? `FS request failed: ${id}`,
229
+ retriable: parsed.error?.code === "ENOENT" || parsed.error?.code === "ENETUNREACH",
230
+ }),
231
+ );
232
+ return;
233
+ }
234
+ resolve(parsed.data);
235
+ } catch (error) {
236
+ reject(
237
+ new Db9FsClientError({
238
+ message: `FS returned invalid response: ${extractErrorMessage(error) ?? String(error)}`,
239
+ retriable: true,
240
+ cause: error,
241
+ }),
242
+ );
243
+ }
244
+ };
245
+
246
+ const onClose = () => {
247
+ cleanup();
248
+ reject(
249
+ new Db9FsClientError({
250
+ message: "FS WebSocket closed while waiting for response",
251
+ retriable: true,
252
+ }),
253
+ );
254
+ };
255
+
256
+ const onError = (error: Error) => {
257
+ cleanup();
258
+ reject(
259
+ new Db9FsClientError({
260
+ message: `FS WebSocket send failed: ${error.message}`,
261
+ retriable: true,
262
+ cause: error,
263
+ }),
264
+ );
265
+ };
266
+
267
+ const cleanup = () => {
268
+ clearTimeout(timeout);
269
+ socket.off("message", onMessage);
270
+ socket.off("close", onClose);
271
+ socket.off("error", onError);
272
+ };
273
+
274
+ socket.once("message", onMessage);
275
+ socket.once("close", onClose);
276
+ socket.once("error", onError);
277
+ socket.send(frame, (error) => {
278
+ if (!error) {
279
+ return;
280
+ }
281
+ cleanup();
282
+ reject(
283
+ new Db9FsClientError({
284
+ message: `FS WebSocket send failed: ${error.message}`,
285
+ retriable: true,
286
+ cause: error,
287
+ }),
288
+ );
289
+ });
290
+ });
291
+ }
292
+
293
+ private async enqueue<T>(fn: () => Promise<T>): Promise<T> {
294
+ const next = this.requestChain.catch(() => undefined).then(fn);
295
+ this.requestChain = next.then(() => undefined, () => undefined);
296
+ return await next;
297
+ }
298
+
299
+ async close(): Promise<void> {
300
+ const socket = this.socket;
301
+ this.socket = null;
302
+ this.authenticated = false;
303
+ this.knownDirs.clear();
304
+ if (!socket) {
305
+ return;
306
+ }
307
+ await new Promise<void>((resolve) => {
308
+ if (socket.readyState === WebSocket.CLOSED) {
309
+ resolve();
310
+ return;
311
+ }
312
+ socket.once("close", () => resolve());
313
+ socket.close();
314
+ setTimeout(resolve, 1_000).unref();
315
+ });
316
+ }
317
+ }