diff --git a/ccw/frontend/src/components/terminal-dashboard/TerminalInstance.tsx b/ccw/frontend/src/components/terminal-dashboard/TerminalInstance.tsx index 7f1c4bc5..fcf29968 100644 --- a/ccw/frontend/src/components/terminal-dashboard/TerminalInstance.tsx +++ b/ccw/frontend/src/components/terminal-dashboard/TerminalInstance.tsx @@ -107,6 +107,11 @@ export function TerminalInstance({ sessionId, className, onRevealPath }: Termina const projectPathRef = useRef(projectPath); projectPathRef.current = projectPath; + // Focus terminal when clicked + const handleTerminalClick = useCallback(() => { + xtermRef.current?.focus(); + }, []); + const handleArtifactClick = useCallback((path: string) => { const resolved = resolveArtifactPath(path, projectPathRef.current); navigator.clipboard.writeText(resolved).catch((err) => { @@ -313,7 +318,7 @@ export function TerminalInstance({ sessionId, className, onRevealPath }: Termina ))} )} -
+
); } diff --git a/ccw/frontend/vite.config.ts b/ccw/frontend/vite.config.ts index 73f557e1..682075aa 100644 --- a/ccw/frontend/vite.config.ts +++ b/ccw/frontend/vite.config.ts @@ -41,7 +41,8 @@ export default defineConfig({ strictPort: true, proxy: { // Backend API proxy - '/api': { + // Use `/api/` (not `/api`) to avoid accidentally proxying frontend routes like `/api-settings`. + '/api/': { target: backendHttpTarget, changeOrigin: true, }, diff --git a/ccw/src/core/server.ts b/ccw/src/core/server.ts index fe99a036..ee11d4b4 100644 --- a/ccw/src/core/server.ts +++ b/ccw/src/core/server.ts @@ -114,7 +114,8 @@ function handlePostRequest(req: http.IncomingMessage, res: http.ServerResponse, if (typeof cachedRawBody === 'string') { try { - void handleBody(JSON.parse(cachedRawBody)); + const trimmed = cachedRawBody.trim(); + void handleBody(trimmed.length === 0 ? {} : JSON.parse(cachedRawBody)); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); res.writeHead(500, { 'Content-Type': 'application/json' }); @@ -128,7 +129,8 @@ function handlePostRequest(req: http.IncomingMessage, res: http.ServerResponse, req.on('end', async () => { try { (req as any).__ccwRawBody = body; - const parsed = JSON.parse(body); + const trimmed = body.trim(); + const parsed = trimmed.length === 0 ? {} : JSON.parse(body); (req as any).body = parsed; await handleBody(parsed); } catch (error: unknown) { diff --git a/ccw/tests/handle-post-request-empty-body.test.js b/ccw/tests/handle-post-request-empty-body.test.js new file mode 100644 index 00000000..c738f787 --- /dev/null +++ b/ccw/tests/handle-post-request-empty-body.test.js @@ -0,0 +1,111 @@ +/** + * Regression test: handlePostRequest should tolerate empty request bodies. + * + * Background: + * - Several endpoints use POST with no JSON payload (e.g. "install"/"uninstall" actions). + * - The server's handlePostRequest previously called JSON.parse(''), throwing: + * "Unexpected end of JSON input". + * + * This test exercises a safe no-body endpoint: + * POST /api/mcp/apply-windows-fix + */ + +import { after, before, describe, it, mock } from 'node:test'; +import assert from 'node:assert/strict'; +import http from 'node:http'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +function httpRequest(options, body, timeout = 10000) { + return new Promise((resolve, reject) => { + const req = http.request(options, (res) => { + let data = ''; + res.on('data', (chunk) => { data += chunk; }); + res.on('end', () => resolve({ + status: res.statusCode || 0, + body: data, + headers: res.headers, + })); + }); + req.on('error', reject); + req.setTimeout(timeout, () => { + req.destroy(); + reject(new Error('Request timeout')); + }); + if (body) req.write(body); + req.end(); + }); +} + +const ORIGINAL_ENV = { ...process.env }; +const serverUrl = new URL('../dist/core/server.js', import.meta.url); +serverUrl.searchParams.set('t', String(Date.now())); + +describe('handlePostRequest (empty body)', async () => { + let server; + let port; + let projectRoot; + let ccwHome; + + before(async () => { + projectRoot = mkdtempSync(join(tmpdir(), 'ccw-empty-body-project-')); + ccwHome = mkdtempSync(join(tmpdir(), 'ccw-empty-body-home-')); + process.env = { ...ORIGINAL_ENV, CCW_DATA_DIR: ccwHome }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const serverMod = await import(serverUrl.href); + + mock.method(console, 'log', () => {}); + mock.method(console, 'error', () => {}); + + server = await serverMod.startServer({ initialPath: projectRoot, port: 0 }); + const addr = server.address(); + port = typeof addr === 'object' && addr ? addr.port : 0; + assert.ok(port > 0, 'Server should start on a valid port'); + }); + + after(async () => { + await new Promise((resolve) => server.close(() => resolve())); + mock.restoreAll(); + process.env = ORIGINAL_ENV; + rmSync(projectRoot, { recursive: true, force: true }); + rmSync(ccwHome, { recursive: true, force: true }); + }); + + it('accepts POST routes with no body', async () => { + const tokenRes = await httpRequest({ + hostname: '127.0.0.1', + port, + path: '/api/auth/token', + method: 'GET', + }); + + assert.equal(tokenRes.status, 200); + const { token } = JSON.parse(tokenRes.body); + assert.ok(typeof token === 'string' && token.length > 0); + + const response = await httpRequest({ + hostname: '127.0.0.1', + port, + path: '/api/mcp/apply-windows-fix', + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + // Make it explicit: empty body + 'Content-Length': '0', + }, + }); + + assert.equal(response.status, 200); + assert.doesNotMatch(response.body, /Unexpected end of JSON input/); + + const payload = JSON.parse(response.body); + assert.equal(payload.success, false); + assert.equal( + payload.message, + 'Auto-fix is not supported. Please install missing commands manually.', + ); + }); +}); +