diff --git a/src/commands/js.test.ts b/src/commands/js.test.ts index b040478..65cc4db 100644 --- a/src/commands/js.test.ts +++ b/src/commands/js.test.ts @@ -105,6 +105,19 @@ describe('jsk js — terminal output capture', () => { } }) + it('strips ANSI color codes from captured terminal output', async () => { + // Mirrors what `console.log(42)` writes when the real stdout is a TTY (colors enabled) — + // unrelated to whether the output is headed to Discord, so it must be stripped regardless. + const { ctx, send } = makeContext('process.stdout.write("\\x1b[33m42\\x1b[39m\\n")') + + await jsCommand.handler(ctx) + + const [payload] = send.mock.calls[0] as [{ content: string }] + expect(payload.content).toContain('42') + expect(payload.content).not.toContain('\x1b') + expect(payload.content).not.toContain('[33m') + }) + it('does not scrub non-token secrets when security mode is off', async () => { process.env.JS_TEST_SECRET_KEY_2 = 'another-secret-value-654321' try { diff --git a/src/commands/js.ts b/src/commands/js.ts index 8f2029d..554173f 100644 --- a/src/commands/js.ts +++ b/src/commands/js.ts @@ -2,7 +2,7 @@ import vm from 'node:vm' import type { Context } from '../context' import { installPrototypeGuards, installRestGuard } from '../prototype-guard' import { guardOutbound } from '../security' -import { inspectResult } from '../util/format' +import { inspectResult, stripAnsi } from '../util/format' import { loadLibraryModule } from '../util/meta' import type { Command } from './registry' @@ -184,7 +184,11 @@ function captureTerminalOutput(scrub: ((text: string) => string) | null): { : Buffer.isBuffer(chunk) ? chunk.toString('utf-8') : String(chunk) - chunks.push(text) + // Stripped for the Discord-bound capture only — colors are decided by whether the + // *real* stdout/stderr is a TTY, which has nothing to do with whether this is headed to + // Discord (not a terminal), so raw escape codes would otherwise show up as literal + // garbage (see stripAnsi's doc comment). The real stream output is left untouched. + chunks.push(stripAnsi(text)) const outgoing = scrub ? scrub(text) : chunk // biome-ignore lint/suspicious/noExplicitAny: forwarding Node's overloaded write(chunk, encoding?, callback?) verbatim. return (original as any).apply(stream, [outgoing, ...rest]) diff --git a/src/util/format.ts b/src/util/format.ts index 350493f..e03ae74 100644 --- a/src/util/format.ts +++ b/src/util/format.ts @@ -13,6 +13,24 @@ export const MAX_PAGINATED_PAGES = 10 /** Zero-width space used to defuse backtick sequences inside codeblocks. */ const ZWSP = '​' +// Matches ANSI escape sequences (colors, cursor movement, etc.). +// biome-ignore lint/suspicious/noControlCharactersInRegex: intentional — stripping terminal control codes. +const ANSI_ESCAPE = /\x1b\[[0-9;?]*[A-Za-z]/g + +/** + * Strips ANSI escape sequences (colors, cursor movement, ...) from `text`. + * + * Discord code blocks aren't a real terminal, so raw escape codes just show up as visible + * garbage (e.g. `console.log(42)` under a color-capable stdout emits `\x1b[33m42\x1b[39m`, + * which renders as literal `[33m42[39m` once the non-printable ESC byte is gone). Node's + * `console`/`util.inspect` decide whether to colorize based on whether the *real* stdout/stderr + * is a TTY — which has nothing to do with whether the output is actually headed to Discord — + * so captured terminal output always needs this before being sent, regardless of environment. + */ +export function stripAnsi(text: string): string { + return text.replace(ANSI_ESCAPE, '') +} + /** * Represents a raw file attachment payload accepted by both discord.js v13 and v14 * (`{ attachment, name }`), avoiding version-specific builder classes. diff --git a/src/util/shell-reader.ts b/src/util/shell-reader.ts index c972687..7ad42de 100644 --- a/src/util/shell-reader.ts +++ b/src/util/shell-reader.ts @@ -1,14 +1,11 @@ import { type ChildProcessWithoutNullStreams, spawn } from 'node:child_process' import { existsSync } from 'node:fs' import type { Encoding, ShellOverride } from '../types' +import { stripAnsi } from './format' const WINDOWS = process.platform === 'win32' const POWERSHELL = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe' -// Matches ANSI escape sequences (colors, cursor movement, etc.). -// biome-ignore lint/suspicious/noControlCharactersInRegex: intentional — stripping terminal control codes. -const ANSI_ESCAPE = /\x1b\[[0-9;?]*[A-Za-z]/g - const ZWSP = '​' function toDecoderLabel(encoding: Encoding): string { @@ -39,7 +36,7 @@ function cmdCodepage(label: string): number { } function cleanLine(line: string): string { - return line.replace(ANSI_ESCAPE, '').replace('\r', '').replaceAll('```', `\`\`${ZWSP}\``) + return stripAnsi(line).replace('\r', '').replaceAll('```', `\`\`${ZWSP}\``) } export interface ShellReaderOptions {