Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/commands/js.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
8 changes: 6 additions & 2 deletions src/commands/js.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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])
Expand Down
18 changes: 18 additions & 0 deletions src/util/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 2 additions & 5 deletions src/util/shell-reader.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
Loading