mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 02:06:43 +00:00
fix(agents): preserve ANSI sanitizer state across bash chunks (#103706)
* fix(agents): preserve ANSI sanitizer state across bash chunks * fix(agents): harden streaming ANSI sanitization Keep incremental parser state in the canonical terminal owner, avoid a second sanitizer pass, and leave OutputAccumulator and public terminal APIs unchanged.\n\nCo-authored-by: Jicheng Xu <xu.jincheng@xydigit.com> --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
co-authored by
Peter Steinberger
parent
ee0e3a4d47
commit
5a81e9fa81
@@ -1,6 +1,8 @@
|
||||
export const ANSI_OSC_INTRODUCER_PATTERN = "(?:\\x1b\\]|\\x9d)";
|
||||
export const ANSI_STRING_TERMINATOR_PATTERN = "(?:\\x1b\\\\|\\x07|\\x9c)";
|
||||
const ANSI_OSC_PATTERN = `${ANSI_OSC_INTRODUCER_PATTERN}[^\\x07\\x1b\\x9c]*${ANSI_STRING_TERMINATOR_PATTERN}`;
|
||||
export const ANSI_COMPAT_CONTROL_SEQUENCE_PATTERN =
|
||||
"[\\u001B\\u009B][[\\]()#;?]*(?:\\d{1,4}(?:[;:]\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]";
|
||||
|
||||
const ansiOscAtIndexRegex = new RegExp(ANSI_OSC_PATTERN, "y");
|
||||
|
||||
@@ -27,6 +29,229 @@ type AnsiCsiScan = {
|
||||
value: string;
|
||||
};
|
||||
|
||||
type AnsiStripState = "text" | "escape" | "osc" | "osc-escape" | "csi" | "compat";
|
||||
|
||||
function isCompatPrefixCode(code: number): boolean {
|
||||
return (
|
||||
code === 0x5b ||
|
||||
code === 0x5d ||
|
||||
code === 0x28 ||
|
||||
code === 0x29 ||
|
||||
code === 0x23 ||
|
||||
code === 0x3b ||
|
||||
code === 0x3f
|
||||
);
|
||||
}
|
||||
|
||||
function isCompatParameterCode(code: number): boolean {
|
||||
return (code >= 0x30 && code <= 0x39) || code === 0x3a || code === 0x3b;
|
||||
}
|
||||
|
||||
function isDigitCode(code: number): boolean {
|
||||
return code >= 0x30 && code <= 0x39;
|
||||
}
|
||||
|
||||
function isCompatFinalCode(code: number): boolean {
|
||||
return (
|
||||
(code >= 0x30 && code <= 0x39) ||
|
||||
(code >= 0x40 && code <= 0x5a) ||
|
||||
code === 0x63 ||
|
||||
(code >= 0x66 && code <= 0x6e) ||
|
||||
(code >= 0x71 && code <= 0x75) ||
|
||||
code === 0x79 ||
|
||||
code === 0x3d ||
|
||||
code === 0x3e ||
|
||||
code === 0x3c ||
|
||||
code === 0x7e
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Incrementally strip the ANSI grammar accepted by the agent output sanitizer.
|
||||
* Parser state stays constant-size so unterminated OSC payloads cannot escape
|
||||
* or accumulate outside the caller's output limits.
|
||||
*/
|
||||
export class AnsiSequenceStripper {
|
||||
private state: AnsiStripState = "text";
|
||||
private csiCompatPrefixOnly = false;
|
||||
private compatInParameters = false;
|
||||
private compatParameterDigits = 0;
|
||||
|
||||
write(input: string): string {
|
||||
if (typeof input !== "string") {
|
||||
throw new TypeError(`Expected a \`string\`, got \`${typeof input}\``);
|
||||
}
|
||||
if (
|
||||
this.state === "text" &&
|
||||
!input.includes("\u001B") &&
|
||||
!input.includes("\u009B") &&
|
||||
!input.includes("\u009D")
|
||||
) {
|
||||
return input;
|
||||
}
|
||||
|
||||
const output: string[] = [];
|
||||
let index = 0;
|
||||
while (index < input.length) {
|
||||
const code = input.charCodeAt(index);
|
||||
|
||||
if (this.state === "text") {
|
||||
if (code === 0x1b) {
|
||||
this.state = "escape";
|
||||
} else if (code === 0x9b) {
|
||||
this.state = "csi";
|
||||
this.csiCompatPrefixOnly = true;
|
||||
} else if (code === 0x9d) {
|
||||
this.state = "osc";
|
||||
} else {
|
||||
output.push(input.charAt(index));
|
||||
}
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.state === "osc") {
|
||||
if (code === 0x07 || code === 0x9c) {
|
||||
this.state = "text";
|
||||
} else if (code === 0x1b) {
|
||||
this.state = "osc-escape";
|
||||
}
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.state === "osc-escape") {
|
||||
if (code === 0x5c || code === 0x07 || code === 0x9c) {
|
||||
this.state = "text";
|
||||
} else if (code !== 0x1b) {
|
||||
this.state = "osc";
|
||||
}
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.state === "csi") {
|
||||
if (code === 0x18 || code === 0x1a) {
|
||||
this.state = "text";
|
||||
index += 1;
|
||||
} else if (code === 0x1b) {
|
||||
this.state = "escape";
|
||||
index += 1;
|
||||
} else if (code === 0x9b) {
|
||||
this.csiCompatPrefixOnly = true;
|
||||
index += 1;
|
||||
} else if (code === 0x9d) {
|
||||
this.state = "osc";
|
||||
index += 1;
|
||||
} else if (code <= 0x1f || code === 0x7f) {
|
||||
output.push(input.charAt(index));
|
||||
index += 1;
|
||||
} else if (code >= 0x20 && code <= 0x3f) {
|
||||
if (!isCompatPrefixCode(code)) {
|
||||
this.csiCompatPrefixOnly = false;
|
||||
}
|
||||
index += 1;
|
||||
} else if ((code === 0x5b || code === 0x5d) && this.csiCompatPrefixOnly) {
|
||||
// The compatibility grammar accepts bracket runs before parameters.
|
||||
// Keep them pending so a chunk split cannot expose the final byte.
|
||||
this.state = "compat";
|
||||
this.compatInParameters = false;
|
||||
this.compatParameterDigits = 0;
|
||||
index += 1;
|
||||
} else if (code >= 0x40 && code <= 0x7e) {
|
||||
this.state = "text";
|
||||
index += 1;
|
||||
} else {
|
||||
this.state = "text";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.state === "escape") {
|
||||
if (code === 0x5d) {
|
||||
this.state = "osc";
|
||||
index += 1;
|
||||
} else if (code === 0x5b) {
|
||||
this.state = "csi";
|
||||
this.csiCompatPrefixOnly = true;
|
||||
index += 1;
|
||||
} else if (code === 0x1b) {
|
||||
index += 1;
|
||||
} else if (code === 0x9b) {
|
||||
this.state = "csi";
|
||||
this.csiCompatPrefixOnly = true;
|
||||
index += 1;
|
||||
} else if (code === 0x9d) {
|
||||
this.state = "osc";
|
||||
index += 1;
|
||||
} else if (isCompatPrefixCode(code)) {
|
||||
this.state = "compat";
|
||||
this.compatInParameters = false;
|
||||
this.compatParameterDigits = 0;
|
||||
index += 1;
|
||||
} else if (isDigitCode(code)) {
|
||||
this.state = "compat";
|
||||
this.compatInParameters = true;
|
||||
this.compatParameterDigits = 1;
|
||||
index += 1;
|
||||
} else if (isCompatFinalCode(code)) {
|
||||
this.state = "text";
|
||||
index += 1;
|
||||
} else {
|
||||
this.state = "text";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (code === 0x18 || code === 0x1a) {
|
||||
this.state = "text";
|
||||
index += 1;
|
||||
} else if (code === 0x1b) {
|
||||
this.state = "escape";
|
||||
index += 1;
|
||||
} else if (code === 0x9b) {
|
||||
this.state = "csi";
|
||||
this.csiCompatPrefixOnly = true;
|
||||
index += 1;
|
||||
} else if (code === 0x9d) {
|
||||
this.state = "osc";
|
||||
index += 1;
|
||||
} else if (!this.compatInParameters && isCompatPrefixCode(code)) {
|
||||
index += 1;
|
||||
} else if (!this.compatInParameters && isDigitCode(code)) {
|
||||
this.compatInParameters = true;
|
||||
this.compatParameterDigits = 1;
|
||||
index += 1;
|
||||
} else if (this.compatInParameters && isCompatParameterCode(code)) {
|
||||
if (code === 0x3a || code === 0x3b) {
|
||||
this.compatParameterDigits = 0;
|
||||
index += 1;
|
||||
} else if (this.compatParameterDigits < 4) {
|
||||
this.compatParameterDigits += 1;
|
||||
index += 1;
|
||||
} else {
|
||||
this.state = "text";
|
||||
index += 1;
|
||||
}
|
||||
} else if (isCompatFinalCode(code)) {
|
||||
this.state = "text";
|
||||
index += 1;
|
||||
} else {
|
||||
this.state = "text";
|
||||
}
|
||||
}
|
||||
return output.join("");
|
||||
}
|
||||
|
||||
finish(): string {
|
||||
this.state = "text";
|
||||
this.csiCompatPrefixOnly = false;
|
||||
this.compatInParameters = false;
|
||||
this.compatParameterDigits = 0;
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/** Scan one CSI parser pass, retaining independently executed C0 controls. */
|
||||
export function scanAnsiCsiAt(input: string, index: number): AnsiCsiScan | undefined {
|
||||
const introducerLength = csiIntroducerLength(input, index);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Terminal Core tests cover ansi behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AnsiSequenceStripper } from "./ansi-sequences.js";
|
||||
import {
|
||||
sanitizeForLog,
|
||||
splitGraphemes,
|
||||
@@ -27,6 +28,76 @@ describe("terminal ansi helpers", () => {
|
||||
expect(stripAnsi("\u001B]unterminated")).toBe("\u001B]unterminated");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["ESC OSC with BEL", ["A\u001B]0;title", "\u0007B"]],
|
||||
["ESC OSC with ESC ST", ["A\u001B]0;title", "\u001B\\B"]],
|
||||
["C1 OSC with C1 ST", ["A\u009D0;title", "\u009CB"]],
|
||||
["C1 OSC with ESC ST", ["A\u009D0;title", "\u001B\\B"]],
|
||||
["ESC CSI", ["A\u001B[31", "mB"]],
|
||||
["C1 CSI", ["A\u009B31", "mB"]],
|
||||
["ESC compatibility charset", ["A\u001B(", "BB"]],
|
||||
["ESC compatibility bracket prefix", ["A\u001B[", "[AB"]],
|
||||
["ESC compatibility mixed prefixes", ["A\u001B(", "[31mB"]],
|
||||
])("strips chunked %s sequences incrementally", (_label, chunks) => {
|
||||
const stripper = new AnsiSequenceStripper();
|
||||
|
||||
const split = chunks.map((chunk) => stripper.write(chunk)).join("") + stripper.finish();
|
||||
const joined = stripAnsiSequences(chunks.join(""));
|
||||
|
||||
expect(split).toBe("AB");
|
||||
expect(split).toBe(joined);
|
||||
});
|
||||
|
||||
it("keeps a trailing ESC pending until the next chunk can identify OSC", () => {
|
||||
const chunks = ["A\u001B", "]0;title\u0007B"];
|
||||
const stripper = new AnsiSequenceStripper();
|
||||
|
||||
const split = chunks.map((chunk) => stripper.write(chunk)).join("") + stripper.finish();
|
||||
const joined = stripAnsiSequences(chunks.join(""));
|
||||
|
||||
expect(split).toBe("AB");
|
||||
expect(split).toBe(joined);
|
||||
});
|
||||
|
||||
it("drops unterminated chunked OSC payload without retaining it until finish", () => {
|
||||
const stripper = new AnsiSequenceStripper();
|
||||
|
||||
const split =
|
||||
stripper.write("line\n\t🙂\u001B]unter") + stripper.write("minated") + stripper.finish();
|
||||
|
||||
expect(split).toBe("line\n\t🙂");
|
||||
});
|
||||
|
||||
it("does not retain large unterminated OSC payloads", () => {
|
||||
const stripper = new AnsiSequenceStripper();
|
||||
const payload = "x".repeat(1024 * 1024);
|
||||
|
||||
const split = stripper.write("safe\u001B]0;") + stripper.write(payload) + stripper.finish();
|
||||
|
||||
expect(split).toBe("safe");
|
||||
});
|
||||
|
||||
it("accepts every standard CSI final byte after a chunk boundary", () => {
|
||||
for (let final = 0x40; final <= 0x7e; final += 1) {
|
||||
const stripper = new AnsiSequenceStripper();
|
||||
const split = stripper.write("A\u001B[31") + stripper.write(`${String.fromCharCode(final)}B`);
|
||||
|
||||
expect(split).toBe("AB");
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
["BEL", "\u0007"],
|
||||
["C1 ST", "\u009C"],
|
||||
])("terminates OSC after stray ESC before %s", (_label, terminator) => {
|
||||
const stripper = new AnsiSequenceStripper();
|
||||
const input = `A\u001B]0;title\u001B${terminator}B`;
|
||||
const split = stripper.write(input.slice(0, -1)) + stripper.write(input.slice(-1));
|
||||
|
||||
expect(stripAnsiSequences(input)).toBe("AB");
|
||||
expect(split).toBe("AB");
|
||||
});
|
||||
|
||||
it("strips the agent output escape grammar without changing text policy", () => {
|
||||
expect(stripAnsiSequences("\u001B[38:5:196mred\u001B[0m")).toBe("red");
|
||||
expect(stripAnsiSequences("\u009B31mred\u009B0m")).toBe("red");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
ANSI_COMPAT_CONTROL_SEQUENCE_PATTERN,
|
||||
ANSI_OSC_INTRODUCER_PATTERN,
|
||||
ANSI_STRING_TERMINATOR_PATTERN,
|
||||
matchAnsiOscAt,
|
||||
@@ -32,10 +33,8 @@ import {
|
||||
* SOFTWARE.
|
||||
*/
|
||||
const ANSI_OSC_SEQUENCE_PATTERN = `${ANSI_OSC_INTRODUCER_PATTERN}[\\s\\S]*?${ANSI_STRING_TERMINATOR_PATTERN}`;
|
||||
const ANSI_CONTROL_SEQUENCE_PATTERN =
|
||||
"[\\u001B\\u009B][[\\]()#;?]*(?:\\d{1,4}(?:[;:]\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]";
|
||||
const ANSI_COMPAT_SEQUENCE_AT_INDEX_REGEX = new RegExp(
|
||||
`${ANSI_OSC_SEQUENCE_PATTERN}|${ANSI_CONTROL_SEQUENCE_PATTERN}`,
|
||||
`${ANSI_OSC_SEQUENCE_PATTERN}|${ANSI_COMPAT_CONTROL_SEQUENCE_PATTERN}`,
|
||||
"y",
|
||||
);
|
||||
const graphemeSegmenter =
|
||||
|
||||
@@ -48,22 +48,89 @@ describe("executeBashWithOperations", () => {
|
||||
await rm(result.fullOutputPath!, { force: true });
|
||||
});
|
||||
|
||||
it("does not consume a pending CSI at an output chunk boundary", async () => {
|
||||
it("sanitizes ANSI and OSC sequences split across output chunks", async () => {
|
||||
const chunks: string[] = [];
|
||||
const operations: BashOperations = {
|
||||
exec: async (_command, _cwd, options) => {
|
||||
options.onData(Buffer.from("\u001b["));
|
||||
options.onData(Buffer.from("Ksecret"));
|
||||
for (const chunk of [
|
||||
"A\u001B]0;title",
|
||||
"\u0007B",
|
||||
"C\u001B[31",
|
||||
"mD",
|
||||
"E\u009D0;title",
|
||||
"\u001B\\F",
|
||||
"G\u009B31",
|
||||
"mH",
|
||||
]) {
|
||||
options.onData(Buffer.from(chunk));
|
||||
}
|
||||
return { exitCode: 0 };
|
||||
},
|
||||
};
|
||||
|
||||
const result = await executeBashWithOperations("printf output", "/tmp", operations, {
|
||||
const result = await executeBashWithOperations("printf styled", "/tmp", operations, {
|
||||
onChunk: (chunk) => chunks.push(chunk),
|
||||
});
|
||||
|
||||
expect(result.output).toBe("\\x1b[Ksecret");
|
||||
expect(chunks.join("")).toBe("\\x1b[Ksecret");
|
||||
expect(chunks.join("")).toBe("ABCDEFGH");
|
||||
expect(result.output).toBe("ABCDEFGH");
|
||||
});
|
||||
|
||||
it("stores sanitized split ANSI output in spilled full output files", async () => {
|
||||
const chunks = ["A\u001B]0;title", "\u0007B\n"];
|
||||
const repeatedChunks = Array.from({ length: 9000 }, (_, index) => chunks[index % 2] ?? "");
|
||||
const expectedOutput = "AB\n".repeat(4500);
|
||||
const operations: BashOperations = {
|
||||
exec: async (_command, _cwd, options) => {
|
||||
for (const chunk of repeatedChunks) {
|
||||
options.onData(Buffer.from(chunk));
|
||||
}
|
||||
return { exitCode: 0 };
|
||||
},
|
||||
};
|
||||
|
||||
const result = await executeBashWithOperations("printf styled", "/tmp", operations);
|
||||
|
||||
expect(result.truncated).toBe(true);
|
||||
expect(result.fullOutputPath).toBeDefined();
|
||||
expect(await readFile(result.fullOutputPath!, "utf8")).toBe(expectedOutput);
|
||||
await rm(result.fullOutputPath!, { force: true });
|
||||
});
|
||||
|
||||
it("sanitizes split compatibility escape grammar sequences", async () => {
|
||||
const chunks: string[] = [];
|
||||
const operations: BashOperations = {
|
||||
exec: async (_command, _cwd, options) => {
|
||||
options.onData(Buffer.from("A\u001B("));
|
||||
options.onData(Buffer.from("BB"));
|
||||
return { exitCode: 0 };
|
||||
},
|
||||
};
|
||||
|
||||
const result = await executeBashWithOperations("printf styled", "/tmp", operations, {
|
||||
onChunk: (chunk) => chunks.push(chunk),
|
||||
});
|
||||
|
||||
expect(chunks.join("")).toBe("AB");
|
||||
expect(result.output).toBe("AB");
|
||||
});
|
||||
|
||||
it("does not retain unterminated OSC payload outside accumulator limits", async () => {
|
||||
const operations: BashOperations = {
|
||||
exec: async (_command, _cwd, options) => {
|
||||
options.onData(Buffer.from("safe\n\u001B]0;"));
|
||||
options.onData(Buffer.from("x".repeat(DEFAULT_MAX_BYTES * 3)));
|
||||
return { exitCode: 0 };
|
||||
},
|
||||
};
|
||||
|
||||
const result = await executeBashWithOperations("printf styled", "/tmp", operations);
|
||||
|
||||
expect(result.output).toBe("safe\n");
|
||||
expect(result.truncated).toBe(false);
|
||||
expect(result.fullOutputPath).toBeDefined();
|
||||
expect(await readFile(result.fullOutputPath!, "utf8")).toBe("safe\n");
|
||||
await rm(result.fullOutputPath!, { force: true });
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* - Direct calls from modes that need bash execution
|
||||
*/
|
||||
|
||||
import { sanitizeBinaryOutput } from "../shell-utils.js";
|
||||
import { createStreamingBinaryOutputSanitizer } from "../shell-utils.js";
|
||||
import type { BashOperations } from "./tools/bash-operations.js";
|
||||
import { OutputAccumulator } from "./tools/output-accumulator.js";
|
||||
|
||||
@@ -48,10 +48,10 @@ export async function executeBashWithOperations(
|
||||
operations: BashOperations,
|
||||
options?: BashExecutorOptions,
|
||||
): Promise<BashResult> {
|
||||
const sanitizeOutput = createStreamingBinaryOutputSanitizer();
|
||||
const output = new OutputAccumulator({
|
||||
tempFilePrefix: "openclaw-bash",
|
||||
transformDecodedText: (text) =>
|
||||
sanitizeBinaryOutput(text, { ansiMode: "compat" }).replace(/\r/g, ""),
|
||||
transformDecodedText: (text) => sanitizeOutput(text).replace(/\r/g, ""),
|
||||
});
|
||||
|
||||
const onData = (data: Buffer) => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { captureEnv } from "../test-utils/env.js";
|
||||
import {
|
||||
buildShellCommandInvocation,
|
||||
createStreamingBinaryOutputSanitizer,
|
||||
detectRuntimeShell,
|
||||
getBashShellConfig,
|
||||
getBashShellEnv,
|
||||
@@ -42,6 +43,16 @@ describe("sanitizeBinaryOutput", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("createStreamingBinaryOutputSanitizer", () => {
|
||||
it("carries ANSI state across process-output chunks", () => {
|
||||
const sanitize = createStreamingBinaryOutputSanitizer();
|
||||
|
||||
expect(sanitize("A\u001b]0;title")).toBe("A");
|
||||
expect(sanitize("\u0007B\u001b[31")).toBe("B");
|
||||
expect(sanitize("mC")).toBe("C");
|
||||
});
|
||||
});
|
||||
|
||||
function createTempCommandDir(
|
||||
tempDirs: string[],
|
||||
files: Array<{ name: string; executable?: boolean }>,
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { AnsiSequenceStripper } from "../../packages/terminal-core/src/ansi-sequences.js";
|
||||
import { stripAnsiForStreamChunk } from "../../packages/terminal-core/src/ansi.js";
|
||||
import {
|
||||
killProcessTree as killProcessTreeGracefully,
|
||||
@@ -361,9 +362,21 @@ export function sanitizeBinaryOutput(
|
||||
): string {
|
||||
// Output callbacks are stream chunks, not true EOF. Preserve a pending CSI
|
||||
// visibly so a split final byte cannot leak from the following chunk.
|
||||
const scrubbed = stripAnsiForStreamChunk(text, {
|
||||
compatibilityGrammar: options?.ansiMode === "compat",
|
||||
}).replace(/[\p{Format}\p{Surrogate}]/gu, "");
|
||||
return sanitizeStrippedBinaryOutput(
|
||||
stripAnsiForStreamChunk(text, {
|
||||
compatibilityGrammar: options?.ansiMode === "compat",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Keep one ANSI parser per process stream so control sequences can span callbacks. */
|
||||
export function createStreamingBinaryOutputSanitizer(): (text: string) => string {
|
||||
const ansiStripper = new AnsiSequenceStripper();
|
||||
return (text) => sanitizeStrippedBinaryOutput(ansiStripper.write(text));
|
||||
}
|
||||
|
||||
function sanitizeStrippedBinaryOutput(text: string): string {
|
||||
const scrubbed = text.replace(/[\p{Format}\p{Surrogate}]/gu, "");
|
||||
if (!scrubbed) {
|
||||
return scrubbed;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user