`vite.config.ts`: **2536 -> 141 Zeilen**. Der Dev-Server liegt jetzt in
`dev-server/` (17 Module) mit 44 eigenen Tests. Die Build-Konfiguration eines
Projekts ist die eine Datei, die jeder anfassen muss und niemand testen kann;
darin steckte ein vollstaendiger HTTP-Server mit Prozessverwaltung.
Die Schnitte folgen GETEILTEM ZUSTAND, nicht Zeilenzahl — `comfy.ts` haelt
einen veraenderlichen Kindprozess samt Logpuffer, `downloads.ts` eine Map
laufender Downloads, `whisper.ts` Prozess und Antwortschlange, `web-search.ts`
einen Verfuegbarkeitsstatus, den Status, Installer und Suchkette teilen. Was
zwei Unabhaengige brauchen (`ssrf.ts`, `python.ts`, `paths.ts`), liegt
daneben statt in einem von beiden. `index.ts` traegt nur noch die Reihenfolge.
Der Port ist ein Parameter: `LU_DEV_PORT` -> `devServerPlugin({ port })`.
Ein Server, dessen Port fest verdrahtet ist, laesst sich nicht zweimal
starten und damit auch nicht pruefen.
Die 15 Lint-Fehler der Datei sind bezahlt, nicht unterdrueckt: 12
`require()`, von denen jedes einen Import SCHATTETE, der oben in derselben
Datei schon stand, und 3 leere `catch` mit dem Grund im Block (Hausstil,
Vorbild `src/api/discover.ts:657`). eslint . faellt damit von 60 auf 45 —
die verbleibenden liegen alle in `src/components`.
Geprueft im eigenen Worktree auf HEAD (nur diese Dateien, node_modules
kopiert statt verlinkt): tsc -b 0 · vite build 0 · madge 0 Zyklen ueber
`src` UND `dev-server` · eslint 45 · vitest 7907 gruen / 0 rot.
Zwei eigene Gegenproben zusaetzlich zu den 12 Sonden des Agenten:
1. ENDPUNKT-PARITAET, mengenweise statt gezaehlt: alle `/local-api/`-Pfade
aus HEADs vite.config.ts gegen alle aus dev-server/ — 46 zu 46, und die
Differenz ist in BEIDE Richtungen leer. Kein Endpunkt verloren, keiner
erfunden.
2. Die REIHENFOLGE, die der Agent als folgenlos meldet. Drei Pfade sind
Praefix eines anderen (`download-model` < `download-model-to-path`,
`fs-read` < `fs-read-bytes`, `transcribe` < `transcribe-status`) — dort
waere ein zu weit greifender Mount ein echter Fehler. Am laufenden Server
auf Port 5401 gemessen: `/transcribe-status` -> **200** (eigener Handler),
`/transcribe` -> 405 (nur POST). Haette der Praefix-Mount zugegriffen,
stuende dort 405. Ohne CSRF-Kopfzeile -> 403, die Sperre greift.
155 lines
5.5 KiB
TypeScript
155 lines
5.5 KiB
TypeScript
import { spawn, execSync } from 'child_process'
|
|
import { mkdirSync, rmSync } from 'fs'
|
|
import os from 'os'
|
|
import { join } from 'path'
|
|
import type { RouteMount } from './routes'
|
|
import { requirePost, withJsonBody } from './http'
|
|
import { bodyNumber, bodyString } from '../src/dev/http-body'
|
|
|
|
/**
|
|
* Die beiden Endpunkte, die aus einem Request heraus einen Prozess starten.
|
|
* Zusammen, weil sie dieselbe Form haben (spawn ohne Shell, Zeitgrenze,
|
|
* SIGKILL) und dieselbe Risikoklasse — sie sind der Grund, warum der Wächter
|
|
* vor /local-api steht.
|
|
*/
|
|
export function registerExecRoutes(routes: RouteMount): void {
|
|
// API: Execute Python code
|
|
routes.use('/local-api/execute-code', (req, res) => {
|
|
if (!requirePost(req, res)) return
|
|
withJsonBody(req, res, (body) => {
|
|
try {
|
|
const code = bodyString(body, 'code')
|
|
const timeoutMs = bodyNumber(body, 'timeout')
|
|
if (!code) {
|
|
res.writeHead(400, { 'Content-Type': 'application/json' })
|
|
res.end(JSON.stringify({ error: 'Missing code parameter' }))
|
|
return
|
|
}
|
|
|
|
const tmpDir = join(os.tmpdir(), 'agent-exec-' + Date.now())
|
|
mkdirSync(tmpDir, { recursive: true })
|
|
|
|
const limit = timeoutMs || 30000
|
|
let stdout = ''
|
|
let stderr = ''
|
|
let killed = false
|
|
|
|
const pythonBin = (() => {
|
|
if (process.platform !== 'win32') return 'python3'
|
|
try {
|
|
// The typed execSync is already imported at the top of this
|
|
// file; the inline require shadowed it with an untyped one.
|
|
const paths = execSync('where python', { encoding: 'utf8' }).trim().split('\n')
|
|
const real = paths.find((p) => !p.includes('WindowsApps'))
|
|
return real ? '"' + real.trim() + '"' : 'python'
|
|
} catch { return 'python' }
|
|
})()
|
|
const proc = spawn(pythonBin, ['-c', code], {
|
|
cwd: tmpDir,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
shell: false,
|
|
})
|
|
|
|
const timer = setTimeout(() => {
|
|
killed = true
|
|
try { proc.kill('SIGKILL') } catch { /* already dead */ }
|
|
}, limit)
|
|
|
|
proc.stdout?.on('data', (d: Buffer) => { stdout += d.toString() })
|
|
proc.stderr?.on('data', (d: Buffer) => { stderr += d.toString() })
|
|
|
|
proc.on('exit', (exitCode) => {
|
|
clearTimeout(timer)
|
|
try { rmSync(tmpDir, { recursive: true, force: true }) } catch { /* ignore */ }
|
|
|
|
if (killed) {
|
|
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
res.end(JSON.stringify({ stdout: '', stderr: 'Execution timed out', exitCode: 124 }))
|
|
return
|
|
}
|
|
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
res.end(JSON.stringify({ stdout, stderr, exitCode: exitCode ?? 1 }))
|
|
})
|
|
|
|
proc.on('error', (err: Error) => {
|
|
clearTimeout(timer)
|
|
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
res.end(JSON.stringify({ stdout: '', stderr: err.message, exitCode: 1 }))
|
|
})
|
|
} catch (err) {
|
|
res.writeHead(400, { 'Content-Type': 'application/json' })
|
|
res.end(JSON.stringify({ error: String(err) }))
|
|
}
|
|
})
|
|
})
|
|
|
|
// --- New Agent Tool Endpoints (Phase 1) ---
|
|
|
|
// API: Shell execute
|
|
routes.use('/local-api/shell-execute', (req, res) => {
|
|
if (!requirePost(req, res)) return
|
|
withJsonBody(req, res, (body) => {
|
|
try {
|
|
const command = bodyString(body, 'command')
|
|
const cwd = bodyString(body, 'cwd')
|
|
const timeoutMs = bodyNumber(body, 'timeout')
|
|
const shellType = bodyString(body, 'shell')
|
|
if (!command) {
|
|
res.writeHead(400, { 'Content-Type': 'application/json' })
|
|
res.end(JSON.stringify({ error: 'Missing command' }))
|
|
return
|
|
}
|
|
|
|
const shellBin = shellType || (process.platform === 'win32' ? 'powershell' : 'bash')
|
|
const shellArgs: string[] = []
|
|
if (shellBin.includes('powershell')) {
|
|
shellArgs.push('-NoProfile', '-NonInteractive', '-Command', command)
|
|
} else if (shellBin.includes('cmd')) {
|
|
shellArgs.push('/C', command)
|
|
} else {
|
|
shellArgs.push('-c', command)
|
|
}
|
|
|
|
const limit = timeoutMs || 120000
|
|
let stdout = ''
|
|
let stderr = ''
|
|
let killed = false
|
|
|
|
const proc = spawn(shellBin, shellArgs, {
|
|
cwd: cwd || undefined,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
shell: false,
|
|
})
|
|
|
|
const timer = setTimeout(() => {
|
|
killed = true
|
|
try { proc.kill('SIGKILL') } catch { /* dead */ }
|
|
}, limit)
|
|
|
|
proc.stdout?.on('data', (d: Buffer) => { stdout += d.toString() })
|
|
proc.stderr?.on('data', (d: Buffer) => { stderr += d.toString() })
|
|
|
|
proc.on('exit', (exitCode) => {
|
|
clearTimeout(timer)
|
|
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
res.end(JSON.stringify({
|
|
stdout, stderr,
|
|
exitCode: killed ? -1 : (exitCode ?? 1),
|
|
timedOut: killed,
|
|
}))
|
|
})
|
|
|
|
proc.on('error', (err: Error) => {
|
|
clearTimeout(timer)
|
|
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
res.end(JSON.stringify({ stdout: '', stderr: err.message, exitCode: 1, timedOut: false }))
|
|
})
|
|
} catch (err) {
|
|
res.writeHead(400, { 'Content-Type': 'application/json' })
|
|
res.end(JSON.stringify({ error: String(err) }))
|
|
}
|
|
})
|
|
})
|
|
|
|
}
|