koishi-plugin-spawn-modified 1.2.7 → 1.2.9
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.
- package/lib/config.d.ts +18 -0
- package/lib/config.js +19 -0
- package/lib/index.d.ts +3 -17
- package/lib/index.js +41 -292
- package/lib/logger.d.ts +11 -0
- package/lib/logger.js +19 -0
- package/lib/render.d.ts +2 -0
- package/lib/render.js +117 -0
- package/lib/utils.d.ts +20 -0
- package/lib/utils.js +206 -0
- package/package.json +1 -1
- package/src/config.ts +33 -0
- package/src/index.ts +35 -409
- package/src/logger.ts +27 -0
- package/src/render.ts +176 -0
- package/src/utils.ts +203 -0
- package/lib/debug-log.d.ts +0 -2
- package/lib/debug-log.js +0 -20
- package/src/debug-log.ts +0 -5
package/src/index.ts
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import { exec } from 'child_process'
|
|
2
|
-
import { Context, h
|
|
3
|
-
import os from 'os'
|
|
2
|
+
import { Context, h } from 'koishi'
|
|
4
3
|
import path from 'path'
|
|
5
|
-
|
|
6
|
-
import
|
|
4
|
+
|
|
5
|
+
import { Config } from './config'
|
|
6
|
+
import { isCommandBlocked, validateCdCommand, validatePathAccess, maskCurlOutput } from './utils'
|
|
7
|
+
import { renderTerminalImage } from './render'
|
|
8
|
+
import { debugLog, debugLogResult } from './logger'
|
|
9
|
+
|
|
10
|
+
// Re-export config for plugin registration
|
|
11
|
+
export { Config } from './config'
|
|
7
12
|
|
|
8
13
|
declare module 'koishi' {
|
|
9
14
|
interface Context {
|
|
@@ -13,36 +18,6 @@ declare module 'koishi' {
|
|
|
13
18
|
}
|
|
14
19
|
}
|
|
15
20
|
|
|
16
|
-
const encodings = ['utf8', 'utf16le', 'latin1', 'ucs2'] as const
|
|
17
|
-
|
|
18
|
-
export interface Config {
|
|
19
|
-
root?: string
|
|
20
|
-
shell?: string
|
|
21
|
-
encoding?: typeof encodings[number]
|
|
22
|
-
timeout?: number
|
|
23
|
-
renderImage?: boolean
|
|
24
|
-
exemptUsers?: string[]
|
|
25
|
-
blockedCommands?: string[]
|
|
26
|
-
restrictDirectory?: boolean
|
|
27
|
-
authority?: number
|
|
28
|
-
commandFilterMode?: 'blacklist' | 'whitelist'
|
|
29
|
-
commandList?: string[]
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export const Config: Schema<Config> = Schema.object({
|
|
33
|
-
root: Schema.string().description('工作路径。').default(''),
|
|
34
|
-
shell: Schema.string().description('运行命令的程序。'),
|
|
35
|
-
encoding: Schema.union(encodings).description('输出内容编码。').default('utf8'),
|
|
36
|
-
timeout: Schema.number().description('最长运行时间。').default(Time.minute),
|
|
37
|
-
renderImage: Schema.boolean().description('是否将命令执行结果渲染为图片(需要安装 puppeteer 插件)。').default(false),
|
|
38
|
-
exemptUsers: Schema.array(String).description('例外用户列表,格式为 "群组ID:用户ID"。私聊时群组ID为0。匹配的用户将无视一切过滤器。').default([]),
|
|
39
|
-
blockedCommands: Schema.array(String).description('违禁命令列表(命令的开头部分)。').default([]),
|
|
40
|
-
restrictDirectory: Schema.boolean().description('是否限制在当前目录及子目录内执行命令(禁止 cd 到上级或其他目录)。').default(false),
|
|
41
|
-
authority: Schema.number().description('exec 命令所需权限等级。').default(4),
|
|
42
|
-
commandFilterMode: Schema.union(['blacklist', 'whitelist']).description('命令过滤模式:blacklist/whitelist').default('blacklist'),
|
|
43
|
-
commandList: Schema.array(String).description('命令过滤列表,配合过滤模式使用(为空则不限制)。').default([]),
|
|
44
|
-
})
|
|
45
|
-
|
|
46
21
|
export interface State {
|
|
47
22
|
command: string
|
|
48
23
|
timeout: number
|
|
@@ -61,378 +36,6 @@ export const inject = {
|
|
|
61
36
|
// 当前工作目录状态管理
|
|
62
37
|
const sessionDirs = new Map<string, string>()
|
|
63
38
|
|
|
64
|
-
// 命令过滤:支持黑名单/白名单模式
|
|
65
|
-
function buildRegex(entry: string): RegExp | null {
|
|
66
|
-
try {
|
|
67
|
-
return new RegExp(entry, 'i')
|
|
68
|
-
} catch (_) {
|
|
69
|
-
// 回退为逐字匹配,防止用户写了非法正则
|
|
70
|
-
const escaped = entry.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
71
|
-
try {
|
|
72
|
-
return new RegExp(escaped, 'i')
|
|
73
|
-
} catch (_) {
|
|
74
|
-
return null
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function isCommandBlocked(command: string, mode: 'blacklist' | 'whitelist', list: string[]): boolean {
|
|
80
|
-
if (!list?.length) return false
|
|
81
|
-
const trimmedCommand = command.trim()
|
|
82
|
-
const hit = list.some(entry => {
|
|
83
|
-
const regex = buildRegex(entry)
|
|
84
|
-
return regex ? regex.test(trimmedCommand) : false
|
|
85
|
-
})
|
|
86
|
-
return mode === 'blacklist' ? hit : !hit
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function stripQuotes(text: string): string {
|
|
90
|
-
return text.replace(/^['"]|['"]$/g, '')
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
function tokenizeCommand(command: string): string[] {
|
|
94
|
-
const tokens: string[] = []
|
|
95
|
-
let current = ''
|
|
96
|
-
let quote: string | null = null
|
|
97
|
-
|
|
98
|
-
for (let i = 0; i < command.length; i++) {
|
|
99
|
-
const char = command[i]
|
|
100
|
-
|
|
101
|
-
if ((char === '"' || char === "'") && (quote === null || quote === char)) {
|
|
102
|
-
quote = quote ? null : char
|
|
103
|
-
continue
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
if (!quote && /\s/.test(char)) {
|
|
107
|
-
if (current) {
|
|
108
|
-
tokens.push(current)
|
|
109
|
-
current = ''
|
|
110
|
-
}
|
|
111
|
-
continue
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
current += char
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
if (current) tokens.push(current)
|
|
118
|
-
return tokens
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
function isPathLike(token: string): boolean {
|
|
122
|
-
const trimmed = token.trim()
|
|
123
|
-
if (!trimmed) return false
|
|
124
|
-
if (/^[|&><]+$/.test(trimmed)) return false
|
|
125
|
-
if (/^-{1,2}[a-zA-Z0-9][\w-]*$/.test(trimmed)) return false
|
|
126
|
-
if (/^\$[A-Za-z_][A-Za-z0-9_]*$/.test(trimmed)) return false
|
|
127
|
-
|
|
128
|
-
const normalized = stripQuotes(trimmed)
|
|
129
|
-
|
|
130
|
-
return (
|
|
131
|
-
/^[A-Za-z]:[\\/]/.test(normalized) ||
|
|
132
|
-
normalized.startsWith('/') ||
|
|
133
|
-
normalized.startsWith('~') ||
|
|
134
|
-
normalized.startsWith('..') ||
|
|
135
|
-
normalized.startsWith('./') ||
|
|
136
|
-
normalized.includes('/') ||
|
|
137
|
-
normalized.includes('\\')
|
|
138
|
-
)
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
function resolveCandidatePath(candidate: string, currentDir: string): string {
|
|
142
|
-
const cleaned = stripQuotes(candidate.trim())
|
|
143
|
-
const homeDir = os.homedir?.() || ''
|
|
144
|
-
|
|
145
|
-
if (cleaned.startsWith('~')) {
|
|
146
|
-
const withoutTilde = cleaned.slice(1).replace(/^[/\\]/, '')
|
|
147
|
-
const homeResolved = homeDir ? path.join(homeDir, withoutTilde) : cleaned
|
|
148
|
-
return path.resolve(homeResolved)
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
return path.resolve(currentDir, cleaned)
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
function extractPathCandidates(command: string): string[] {
|
|
155
|
-
const tokens = tokenizeCommand(command)
|
|
156
|
-
const candidates: string[] = []
|
|
157
|
-
|
|
158
|
-
for (const token of tokens) {
|
|
159
|
-
const normalized = stripQuotes(token)
|
|
160
|
-
if (isPathLike(normalized)) {
|
|
161
|
-
candidates.push(normalized)
|
|
162
|
-
continue
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
const eqIndex = normalized.indexOf('=')
|
|
166
|
-
if (eqIndex > 0) {
|
|
167
|
-
const value = normalized.slice(eqIndex + 1)
|
|
168
|
-
if (isPathLike(value)) {
|
|
169
|
-
candidates.push(value)
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
return candidates
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
// 解析 cd 命令并验证路径
|
|
178
|
-
function isWithinRoot(rootDir: string, targetPath: string): boolean {
|
|
179
|
-
const relative = path.relative(rootDir, targetPath)
|
|
180
|
-
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
function validatePathAccess(command: string, currentDir: string, rootDir: string, restrictDirectory: boolean): { valid: boolean; error?: string } {
|
|
184
|
-
if (!restrictDirectory) return { valid: true }
|
|
185
|
-
|
|
186
|
-
const normalizedRoot = path.resolve(rootDir)
|
|
187
|
-
const candidates = extractPathCandidates(command)
|
|
188
|
-
|
|
189
|
-
for (const candidate of candidates) {
|
|
190
|
-
const resolved = resolveCandidatePath(candidate, currentDir)
|
|
191
|
-
if (!isWithinRoot(normalizedRoot, resolved)) {
|
|
192
|
-
return { valid: false, error: 'restricted-path' }
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
return { valid: true }
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
function validateCdCommand(command: string, currentDir: string, rootDir: string, restrictDirectory: boolean): { valid: boolean; newDir?: string; error?: string } {
|
|
200
|
-
if (!restrictDirectory) return { valid: true }
|
|
201
|
-
|
|
202
|
-
const normalizedRoot = path.resolve(rootDir)
|
|
203
|
-
const cdMatches: RegExpExecArray[] = []
|
|
204
|
-
const cdRegex = /\bcd\s+([^;&|\n]+)/gi
|
|
205
|
-
let m: RegExpExecArray | null
|
|
206
|
-
while ((m = cdRegex.exec(command)) !== null) {
|
|
207
|
-
cdMatches.push(m)
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
if (!cdMatches.length) return { valid: true }
|
|
211
|
-
|
|
212
|
-
// 若命令被链式运算符分隔且包含 cd,则要求所有 cd 目标都在指定 root 下,否则拒绝
|
|
213
|
-
for (const match of cdMatches) {
|
|
214
|
-
const target = match[1].trim().replace(/['"]/g, '')
|
|
215
|
-
const absolutePath = path.resolve(currentDir, target)
|
|
216
|
-
if (!isWithinRoot(normalizedRoot, absolutePath)) {
|
|
217
|
-
return { valid: false, error: 'restricted-directory' }
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
// 仅当命令是单独的 cd 时才更新会话目录,避免链式命令切换目录后执行其他操作
|
|
222
|
-
const singleCdOnly = /^\s*cd\s+[^;&|\n]+\s*$/i.test(command)
|
|
223
|
-
if (singleCdOnly) {
|
|
224
|
-
const target = cdMatches[0][1].trim().replace(/['"]/g, '')
|
|
225
|
-
const absolutePath = path.resolve(currentDir, target)
|
|
226
|
-
return { valid: true, newDir: absolutePath }
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
return { valid: true }
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
function maskCurlOutput(command: string, output: string): string {
|
|
233
|
-
if (!output) return output
|
|
234
|
-
if (!/\bcurl\b/i.test(command)) return output
|
|
235
|
-
|
|
236
|
-
const ipv4Regex = /\b(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)\b/g
|
|
237
|
-
return output.replace(ipv4Regex, (ip) => (isPrivateIpv4(ip) ? ip : '*.*.*.*'))
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
function isPrivateIpv4(ip: string): boolean {
|
|
241
|
-
const octets = ip.split('.').map(Number)
|
|
242
|
-
if (octets.length !== 4) return false
|
|
243
|
-
if (octets.some(octet => Number.isNaN(octet) || octet < 0 || octet > 255)) return false
|
|
244
|
-
|
|
245
|
-
const [a, b] = octets
|
|
246
|
-
|
|
247
|
-
if (a === 10) return true
|
|
248
|
-
if (a === 172 && b >= 16 && b <= 31) return true
|
|
249
|
-
if (a === 192 && b === 168) return true
|
|
250
|
-
if (a === 127) return true
|
|
251
|
-
if (a === 169 && b === 254) return true
|
|
252
|
-
|
|
253
|
-
return false
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
// 渲染终端输出为图片
|
|
257
|
-
async function renderTerminalImage(ctx: Context, workingDir: string, command: string, output: string): Promise<h> {
|
|
258
|
-
if (!ctx.puppeteer) {
|
|
259
|
-
throw new Error('Puppeteer plugin is not available')
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
const ansiStrip = (text: string) => text.replace(/\x1B\[[0-9;]*[A-Za-z]/g, '')
|
|
263
|
-
const normalizeTabs = (text: string) => text.replace(/\t/g, ' ')
|
|
264
|
-
const displayOutputRaw = normalizeTabs(output || '(no output)')
|
|
265
|
-
const displayOutput = displayOutputRaw.replace(/^\s+/, '')
|
|
266
|
-
const lines = displayOutput.split(/\r?\n/)
|
|
267
|
-
const commandLineLength = ansiStrip(`${workingDir}$ ${command}`).length
|
|
268
|
-
const visibleLineLengths = lines.map(line => ansiStrip(line).length)
|
|
269
|
-
const maxLineLength = Math.max(commandLineLength, ...visibleLineLengths) || commandLineLength
|
|
270
|
-
const charWidth = 7.1 // refined average width for JetBrains Mono 13px
|
|
271
|
-
const horizontalBuffer = 56 // padding + borders + margin buffer
|
|
272
|
-
const containerWidth = Math.max(600, Math.min(1600, Math.ceil(maxLineLength * charWidth + horizontalBuffer)))
|
|
273
|
-
|
|
274
|
-
const ansi = new AnsiToHtml({
|
|
275
|
-
fg: '#cccccc',
|
|
276
|
-
bg: '#1e1e1e',
|
|
277
|
-
newline: true,
|
|
278
|
-
escapeXML: true,
|
|
279
|
-
stream: false,
|
|
280
|
-
})
|
|
281
|
-
const coloredOutputHtml = ansi.toHtml(displayOutput)
|
|
282
|
-
|
|
283
|
-
const fontPath = pathToFileURL(path.resolve(__dirname, '../fonts/JetBrainsMono-Regular.ttf')).href
|
|
284
|
-
|
|
285
|
-
const html = `
|
|
286
|
-
<!DOCTYPE html>
|
|
287
|
-
<html>
|
|
288
|
-
<head>
|
|
289
|
-
<meta charset="UTF-8">
|
|
290
|
-
<style>
|
|
291
|
-
@font-face {
|
|
292
|
-
font-family: 'JetBrains Mono';
|
|
293
|
-
src: url('${fontPath}') format('truetype');
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
* {
|
|
297
|
-
margin: 0;
|
|
298
|
-
padding: 0;
|
|
299
|
-
box-sizing: border-box;
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
body {
|
|
303
|
-
background: #1e1e1e;
|
|
304
|
-
color: #cccccc;
|
|
305
|
-
font-family: 'JetBrains Mono', 'Courier New', monospace;
|
|
306
|
-
font-weight: 400;
|
|
307
|
-
font-size: 13px;
|
|
308
|
-
padding: 0;
|
|
309
|
-
display: inline-block;
|
|
310
|
-
width: ${containerWidth}px;
|
|
311
|
-
max-width: 1600px;
|
|
312
|
-
min-width: 600px;
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
.terminal {
|
|
316
|
-
background: #1e1e1e;
|
|
317
|
-
border: 1px solid #3c3c3c;
|
|
318
|
-
border-radius: 8px;
|
|
319
|
-
overflow: hidden;
|
|
320
|
-
width: 100%;
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
.title-bar {
|
|
324
|
-
background: #2d2d2d;
|
|
325
|
-
height: 35px;
|
|
326
|
-
display: flex;
|
|
327
|
-
align-items: center;
|
|
328
|
-
justify-content: space-between;
|
|
329
|
-
padding: 0 12px;
|
|
330
|
-
border-bottom: 1px solid #3c3c3c;
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
.title {
|
|
334
|
-
color: #cccccc;
|
|
335
|
-
font-size: 13px;
|
|
336
|
-
font-weight: 500;
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
.buttons {
|
|
340
|
-
display: flex;
|
|
341
|
-
gap: 8px;
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
.button {
|
|
345
|
-
width: 12px;
|
|
346
|
-
height: 12px;
|
|
347
|
-
border-radius: 50%;
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
.button.minimize { background: #ffbd2e; }
|
|
351
|
-
.button.maximize { background: #28c940; }
|
|
352
|
-
.button.close { background: #ff5f56; }
|
|
353
|
-
|
|
354
|
-
.content {
|
|
355
|
-
padding: 8px 12px;
|
|
356
|
-
white-space: pre;
|
|
357
|
-
word-break: normal;
|
|
358
|
-
line-height: 1.18;
|
|
359
|
-
overflow-x: auto;
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
.command-line {
|
|
363
|
-
display: flex;
|
|
364
|
-
gap: 3px;
|
|
365
|
-
align-items: baseline;
|
|
366
|
-
margin-bottom: 2px;
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
.prompt {
|
|
370
|
-
color: #4ec9b0;
|
|
371
|
-
margin: 0;
|
|
372
|
-
flex-shrink: 0;
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
.command {
|
|
376
|
-
color: #dcdcaa;
|
|
377
|
-
margin: 0;
|
|
378
|
-
word-break: normal;
|
|
379
|
-
flex: 1;
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
.output {
|
|
383
|
-
color: #cccccc;
|
|
384
|
-
line-height: 1.12;
|
|
385
|
-
white-space: pre;
|
|
386
|
-
word-break: normal;
|
|
387
|
-
overflow-x: auto;
|
|
388
|
-
}
|
|
389
|
-
</style>
|
|
390
|
-
</head>
|
|
391
|
-
<body>
|
|
392
|
-
<div class="terminal">
|
|
393
|
-
<div class="title-bar">
|
|
394
|
-
<div class="title">Terminal</div>
|
|
395
|
-
<div class="buttons">
|
|
396
|
-
<div class="button minimize"></div>
|
|
397
|
-
<div class="button maximize"></div>
|
|
398
|
-
<div class="button close"></div>
|
|
399
|
-
</div>
|
|
400
|
-
</div>
|
|
401
|
-
<div class="content">
|
|
402
|
-
<div class="command-line">
|
|
403
|
-
<div class="prompt">${escapeHtml(workingDir)}$</div>
|
|
404
|
-
<div class="command">${escapeHtml(command)}</div>
|
|
405
|
-
</div>
|
|
406
|
-
<div class="output">${coloredOutputHtml}</div>
|
|
407
|
-
</div>
|
|
408
|
-
</div>
|
|
409
|
-
</body>
|
|
410
|
-
</html>
|
|
411
|
-
`
|
|
412
|
-
|
|
413
|
-
const page = await ctx.puppeteer.page()
|
|
414
|
-
try {
|
|
415
|
-
await page.setContent(html)
|
|
416
|
-
await page.waitForNetworkIdle({ timeout: 5000 })
|
|
417
|
-
|
|
418
|
-
const element = await page.$('.terminal')
|
|
419
|
-
const screenshot = await element.screenshot({ type: 'png' }) as Buffer
|
|
420
|
-
|
|
421
|
-
return h.image(screenshot, 'image/png')
|
|
422
|
-
} finally {
|
|
423
|
-
await page.close()
|
|
424
|
-
}
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
function escapeHtml(text: string): string {
|
|
428
|
-
return text
|
|
429
|
-
.replace(/&/g, '&')
|
|
430
|
-
.replace(/</g, '<')
|
|
431
|
-
.replace(/>/g, '>')
|
|
432
|
-
.replace(/"/g, '"')
|
|
433
|
-
.replace(/'/g, ''')
|
|
434
|
-
}
|
|
435
|
-
|
|
436
39
|
export function apply(ctx: Context, config: Config) {
|
|
437
40
|
ctx.i18n.define('zh-CN', require('./locales/zh-CN'))
|
|
438
41
|
|
|
@@ -450,15 +53,26 @@ export function apply(ctx: Context, config: Config) {
|
|
|
450
53
|
const userKey = `${guildId}:${userId}`
|
|
451
54
|
const isExempt = config.exemptUsers?.some(entry => entry === userKey) ?? false
|
|
452
55
|
|
|
56
|
+
const sessionId = session.uid || session.channelId
|
|
57
|
+
const rootDir = path.resolve(ctx.baseDir, config.root)
|
|
58
|
+
const currentDir = sessionDirs.get(sessionId) || rootDir
|
|
59
|
+
|
|
60
|
+
// 输出调试信息
|
|
61
|
+
debugLog(ctx, config, {
|
|
62
|
+
guildId,
|
|
63
|
+
userId,
|
|
64
|
+
command,
|
|
65
|
+
isExempt,
|
|
66
|
+
currentDir,
|
|
67
|
+
})
|
|
68
|
+
|
|
453
69
|
// 检查命令过滤(黑/白名单);仅使用配置提供的正则
|
|
454
70
|
const filterList = (config.commandList?.length ? config.commandList : config.blockedCommands) || []
|
|
455
71
|
const filterMode = config.commandFilterMode || 'blacklist'
|
|
456
72
|
if (!isExempt && isCommandBlocked(command, filterMode, filterList)) {
|
|
457
73
|
return session.text('.blocked-command')
|
|
458
74
|
}
|
|
459
|
-
|
|
460
|
-
const rootDir = path.resolve(ctx.baseDir, config.root)
|
|
461
|
-
const currentDir = sessionDirs.get(sessionId) || rootDir
|
|
75
|
+
|
|
462
76
|
// 验证 cd 命令
|
|
463
77
|
const cdValidation = validateCdCommand(command, currentDir, rootDir, !isExempt && config.restrictDirectory)
|
|
464
78
|
if (!cdValidation.valid) {
|
|
@@ -468,11 +82,13 @@ export function apply(ctx: Context, config: Config) {
|
|
|
468
82
|
if (!pathValidation.valid) {
|
|
469
83
|
return session.text('.restricted-path')
|
|
470
84
|
}
|
|
85
|
+
|
|
471
86
|
const { timeout } = config
|
|
472
87
|
const state: State = { command, timeout, output: '' }
|
|
473
88
|
if (!config.renderImage) {
|
|
474
89
|
await session.send(session.text('.started', state))
|
|
475
90
|
}
|
|
91
|
+
|
|
476
92
|
return new Promise((resolve) => {
|
|
477
93
|
const start = Date.now()
|
|
478
94
|
const child = exec(command, {
|
|
@@ -493,10 +109,20 @@ export function apply(ctx: Context, config: Config) {
|
|
|
493
109
|
state.signal = signal
|
|
494
110
|
state.timeUsed = Date.now() - start
|
|
495
111
|
state.output = maskCurlOutput(command, state.output.trim())
|
|
112
|
+
|
|
113
|
+
// 输出执行结果调试信息
|
|
114
|
+
debugLogResult(ctx, config, code, state.timeUsed)
|
|
115
|
+
|
|
496
116
|
// 更新当前目录(如果是 cd 命令且执行成功)
|
|
497
117
|
if (cdValidation.newDir && code === 0) {
|
|
498
118
|
sessionDirs.set(sessionId, cdValidation.newDir)
|
|
499
119
|
}
|
|
120
|
+
|
|
121
|
+
// 例外用户先回复"命令成功执行",再尝试渲染结果
|
|
122
|
+
if (isExempt) {
|
|
123
|
+
await session.send('命令成功执行')
|
|
124
|
+
}
|
|
125
|
+
|
|
500
126
|
// 渲染为图片或返回文本
|
|
501
127
|
if (config.renderImage && ctx.puppeteer) {
|
|
502
128
|
try {
|
package/src/logger.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Context, Logger } from 'koishi'
|
|
2
|
+
import { Config } from './config'
|
|
3
|
+
|
|
4
|
+
const logger = new Logger('spawn')
|
|
5
|
+
|
|
6
|
+
export interface DebugInfo {
|
|
7
|
+
guildId: string
|
|
8
|
+
userId: string
|
|
9
|
+
command: string
|
|
10
|
+
isExempt: boolean
|
|
11
|
+
currentDir: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function debugLog(ctx: Context, config: Config, info: DebugInfo) {
|
|
15
|
+
if (!config.debug) return
|
|
16
|
+
|
|
17
|
+
logger.info(`[DEBUG] 群组ID: ${info.guildId}, 用户ID: ${info.userId}`)
|
|
18
|
+
logger.info(`[DEBUG] 命令: ${info.command}`)
|
|
19
|
+
logger.info(`[DEBUG] 例外用户: ${info.isExempt ? '是' : '否'}`)
|
|
20
|
+
logger.info(`[DEBUG] 工作目录: ${info.currentDir}`)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function debugLogResult(ctx: Context, config: Config, code: number | undefined, timeUsed: number) {
|
|
24
|
+
if (!config.debug) return
|
|
25
|
+
|
|
26
|
+
logger.info(`[DEBUG] 执行结果: 退出码=${code}, 耗时=${timeUsed}ms`)
|
|
27
|
+
}
|
package/src/render.ts
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { Context, h } from 'koishi'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
import { pathToFileURL } from 'url'
|
|
4
|
+
import AnsiToHtml from 'ansi-to-html'
|
|
5
|
+
import { escapeHtml } from './utils'
|
|
6
|
+
|
|
7
|
+
// 渲染终端输出为图片
|
|
8
|
+
export async function renderTerminalImage(ctx: Context, workingDir: string, command: string, output: string): Promise<h> {
|
|
9
|
+
if (!ctx.puppeteer) {
|
|
10
|
+
throw new Error('Puppeteer plugin is not available')
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const ansiStrip = (text: string) => text.replace(/\x1B\[[0-9;]*[A-Za-z]/g, '')
|
|
14
|
+
const normalizeTabs = (text: string) => text.replace(/\t/g, ' ')
|
|
15
|
+
const displayOutputRaw = normalizeTabs(output || '(no output)')
|
|
16
|
+
const displayOutput = displayOutputRaw.replace(/^\s+/, '')
|
|
17
|
+
const lines = displayOutput.split(/\r?\n/)
|
|
18
|
+
const commandLineLength = ansiStrip(`${workingDir}$ ${command}`).length
|
|
19
|
+
const visibleLineLengths = lines.map(line => ansiStrip(line).length)
|
|
20
|
+
const maxLineLength = Math.max(commandLineLength, ...visibleLineLengths) || commandLineLength
|
|
21
|
+
const charWidth = 7.1 // refined average width for JetBrains Mono 13px
|
|
22
|
+
const horizontalBuffer = 56 // padding + borders + margin buffer
|
|
23
|
+
const containerWidth = Math.max(600, Math.min(1600, Math.ceil(maxLineLength * charWidth + horizontalBuffer)))
|
|
24
|
+
|
|
25
|
+
const ansi = new AnsiToHtml({
|
|
26
|
+
fg: '#cccccc',
|
|
27
|
+
bg: '#1e1e1e',
|
|
28
|
+
newline: true,
|
|
29
|
+
escapeXML: true,
|
|
30
|
+
stream: false,
|
|
31
|
+
})
|
|
32
|
+
const coloredOutputHtml = ansi.toHtml(displayOutput)
|
|
33
|
+
|
|
34
|
+
const fontPath = pathToFileURL(path.resolve(__dirname, '../fonts/JetBrainsMono-Regular.ttf')).href
|
|
35
|
+
|
|
36
|
+
const html = `
|
|
37
|
+
<!DOCTYPE html>
|
|
38
|
+
<html>
|
|
39
|
+
<head>
|
|
40
|
+
<meta charset="UTF-8">
|
|
41
|
+
<style>
|
|
42
|
+
@font-face {
|
|
43
|
+
font-family: 'JetBrains Mono';
|
|
44
|
+
src: url('${fontPath}') format('truetype');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
* {
|
|
48
|
+
margin: 0;
|
|
49
|
+
padding: 0;
|
|
50
|
+
box-sizing: border-box;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
body {
|
|
54
|
+
background: #1e1e1e;
|
|
55
|
+
color: #cccccc;
|
|
56
|
+
font-family: 'JetBrains Mono', 'Courier New', monospace;
|
|
57
|
+
font-weight: 400;
|
|
58
|
+
font-size: 13px;
|
|
59
|
+
padding: 0;
|
|
60
|
+
display: inline-block;
|
|
61
|
+
width: ${containerWidth}px;
|
|
62
|
+
max-width: 1600px;
|
|
63
|
+
min-width: 600px;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.terminal {
|
|
67
|
+
background: #1e1e1e;
|
|
68
|
+
border: 1px solid #3c3c3c;
|
|
69
|
+
border-radius: 8px;
|
|
70
|
+
overflow: hidden;
|
|
71
|
+
width: 100%;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.title-bar {
|
|
75
|
+
background: #2d2d2d;
|
|
76
|
+
height: 35px;
|
|
77
|
+
display: flex;
|
|
78
|
+
align-items: center;
|
|
79
|
+
justify-content: space-between;
|
|
80
|
+
padding: 0 12px;
|
|
81
|
+
border-bottom: 1px solid #3c3c3c;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.title {
|
|
85
|
+
color: #cccccc;
|
|
86
|
+
font-size: 13px;
|
|
87
|
+
font-weight: 500;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.buttons {
|
|
91
|
+
display: flex;
|
|
92
|
+
gap: 8px;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.button {
|
|
96
|
+
width: 12px;
|
|
97
|
+
height: 12px;
|
|
98
|
+
border-radius: 50%;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.button.minimize { background: #ffbd2e; }
|
|
102
|
+
.button.maximize { background: #28c940; }
|
|
103
|
+
.button.close { background: #ff5f56; }
|
|
104
|
+
|
|
105
|
+
.content {
|
|
106
|
+
padding: 8px 12px;
|
|
107
|
+
white-space: pre;
|
|
108
|
+
word-break: normal;
|
|
109
|
+
line-height: 1.18;
|
|
110
|
+
overflow-x: auto;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.command-line {
|
|
114
|
+
display: flex;
|
|
115
|
+
gap: 3px;
|
|
116
|
+
align-items: baseline;
|
|
117
|
+
margin-bottom: 2px;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
.prompt {
|
|
121
|
+
color: #4ec9b0;
|
|
122
|
+
margin: 0;
|
|
123
|
+
flex-shrink: 0;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.command {
|
|
127
|
+
color: #dcdcaa;
|
|
128
|
+
margin: 0;
|
|
129
|
+
word-break: normal;
|
|
130
|
+
flex: 1;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.output {
|
|
134
|
+
color: #cccccc;
|
|
135
|
+
line-height: 1.12;
|
|
136
|
+
white-space: pre;
|
|
137
|
+
word-break: normal;
|
|
138
|
+
overflow-x: auto;
|
|
139
|
+
}
|
|
140
|
+
</style>
|
|
141
|
+
</head>
|
|
142
|
+
<body>
|
|
143
|
+
<div class="terminal">
|
|
144
|
+
<div class="title-bar">
|
|
145
|
+
<div class="title">Terminal</div>
|
|
146
|
+
<div class="buttons">
|
|
147
|
+
<div class="button minimize"></div>
|
|
148
|
+
<div class="button maximize"></div>
|
|
149
|
+
<div class="button close"></div>
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
<div class="content">
|
|
153
|
+
<div class="command-line">
|
|
154
|
+
<div class="prompt">${escapeHtml(workingDir)}$</div>
|
|
155
|
+
<div class="command">${escapeHtml(command)}</div>
|
|
156
|
+
</div>
|
|
157
|
+
<div class="output">${coloredOutputHtml}</div>
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
</body>
|
|
161
|
+
</html>
|
|
162
|
+
`
|
|
163
|
+
|
|
164
|
+
const page = await ctx.puppeteer.page()
|
|
165
|
+
try {
|
|
166
|
+
await page.setContent(html)
|
|
167
|
+
await page.waitForNetworkIdle({ timeout: 5000 })
|
|
168
|
+
|
|
169
|
+
const element = await page.$('.terminal')
|
|
170
|
+
const screenshot = await element.screenshot({ type: 'png' }) as Buffer
|
|
171
|
+
|
|
172
|
+
return h.image(screenshot, 'image/png')
|
|
173
|
+
} finally {
|
|
174
|
+
await page.close()
|
|
175
|
+
}
|
|
176
|
+
}
|