deckide 3.5.40 → 3.5.42
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/dist/config.js +28 -0
- package/dist/routes/codeserver.js +79 -0
- package/dist/routes/files.js +72 -0
- package/dist/server.js +22 -2
- package/dist/utils/agent-browser.js +63 -11
- package/dist/utils/browser-maintenance.js +145 -0
- package/dist/utils/browser-webrtc.js +471 -0
- package/dist/utils/code-server.js +291 -0
- package/dist/websocket.js +54 -2
- package/package.json +5 -2
- package/web/dist/assets/index-C1OzsVds.css +32 -0
- package/web/dist/assets/index-DFNjEZYB.js +178 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-CdzOg4rb.css +0 -32
- package/web/dist/assets/index-D2wX1H1J.js +0 -178
package/dist/config.js
CHANGED
|
@@ -35,6 +35,34 @@ export const MAX_FILE_SIZE = parseIntEnv(process.env.MAX_FILE_SIZE, 10 * 1024 *
|
|
|
35
35
|
export const TERMINAL_BUFFER_LIMIT = parseIntEnv(process.env.TERMINAL_BUFFER_LIMIT, 500_000);
|
|
36
36
|
export const MAX_REQUEST_BODY_SIZE = parseIntEnv(process.env.MAX_REQUEST_BODY_SIZE, 1024 * 1024); // 1MB default
|
|
37
37
|
export const TRUST_PROXY = process.env.TRUST_PROXY === 'true'; // Only trust proxy headers if explicitly enabled
|
|
38
|
+
// --- Agent browser WebRTC streaming (A1: direct Xvfb capture → ffmpeg → werift) ---
|
|
39
|
+
// Master switch. 'false' (default) keeps the JPEG screencast path and never even
|
|
40
|
+
// imports werift. 'true'/'auto' advertise the WebRTC capability; the client still
|
|
41
|
+
// falls back to JPEG on any failure.
|
|
42
|
+
const webrtcFlag = (process.env.AGENT_BROWSER_WEBRTC || 'false').toLowerCase();
|
|
43
|
+
export const AGENT_BROWSER_WEBRTC = webrtcFlag === 'true' || webrtcFlag === 'auto';
|
|
44
|
+
// Video codec for the encoded stream. Only 'vp8' is wired today; 'vp9'/'h264'
|
|
45
|
+
// are accepted for forward-compat and validated in the relay.
|
|
46
|
+
export const AGENT_BROWSER_WEBRTC_CODEC = (process.env.AGENT_BROWSER_WEBRTC_CODEC || 'vp8').toLowerCase();
|
|
47
|
+
// Target video bitrate passed to the encoder (ffmpeg -b:v syntax, e.g. '3M').
|
|
48
|
+
export const AGENT_BROWSER_WEBRTC_BITRATE = process.env.AGENT_BROWSER_WEBRTC_BITRATE || '3M';
|
|
49
|
+
export const AGENT_BROWSER_WEBRTC_FPS = parseIntEnv(process.env.AGENT_BROWSER_WEBRTC_FPS, 30);
|
|
50
|
+
// Optional explicit X display to capture (e.g. ':99'). When unset the relay
|
|
51
|
+
// discovers it from the running Chrome process environment.
|
|
52
|
+
export const AGENT_BROWSER_DISPLAY = process.env.AGENT_BROWSER_DISPLAY || '';
|
|
53
|
+
// Optional ICE servers as JSON (e.g. '[{"urls":"stun:stun.l.google.com:19302"}]').
|
|
54
|
+
// Empty = host candidates only, which is correct for same-host / LAN.
|
|
55
|
+
export const AGENT_BROWSER_WEBRTC_ICE_SERVERS = process.env.AGENT_BROWSER_WEBRTC_ICE_SERVERS || '';
|
|
56
|
+
// --- Shared-browser maintenance ---
|
|
57
|
+
// CDP port the shared agent browser listens on (must match agent-browser's).
|
|
58
|
+
export const AGENT_BROWSER_CDP_PORT = parseIntEnv(process.env.AGENT_BROWSER_CDP_PORT, 9222);
|
|
59
|
+
// Reap leaked chrome-devtools-mcp server stacks when one agent accumulates too
|
|
60
|
+
// many (the observed leak). On by default; set 'false' to disable.
|
|
61
|
+
export const AGENT_BROWSER_REAP_MCP = (process.env.AGENT_BROWSER_REAP_MCP || 'true').toLowerCase() !== 'false';
|
|
62
|
+
// Max chrome-devtools-mcp stacks a single agent may keep before old extras are reaped.
|
|
63
|
+
export const AGENT_BROWSER_MAX_MCP_PER_AGENT = parseIntEnv(process.env.AGENT_BROWSER_MAX_MCP_PER_AGENT, 3);
|
|
64
|
+
// Extra stacks must be older than this before being reaped (spares restart bursts).
|
|
65
|
+
export const AGENT_BROWSER_MCP_IDLE_MS = parseIntEnv(process.env.AGENT_BROWSER_MCP_IDLE_MS, 600_000);
|
|
38
66
|
// Flat structure: dist/ and web/dist/ are siblings at project root
|
|
39
67
|
export const distDir = path.resolve(__dirname, '..', 'web', 'dist');
|
|
40
68
|
export const hasStatic = fsSync.existsSync(distDir);
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
// Hop-by-hop / encoding headers that must not be forwarded verbatim. undici's
|
|
3
|
+
// fetch already decompresses the body, so leaving content-encoding/length on the
|
|
4
|
+
// response would make the browser try to decompress decoded bytes.
|
|
5
|
+
const STRIP_RESPONSE_HEADERS = ['content-encoding', 'content-length', 'transfer-encoding', 'connection'];
|
|
6
|
+
async function proxy(c, codeServer) {
|
|
7
|
+
const wsId = c.req.param('wsId');
|
|
8
|
+
if (!wsId) {
|
|
9
|
+
return c.json({ error: 'workspace id required' }, 400);
|
|
10
|
+
}
|
|
11
|
+
let port;
|
|
12
|
+
try {
|
|
13
|
+
port = await codeServer.ensure(wsId);
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
17
|
+
return c.json({ error: message }, 503);
|
|
18
|
+
}
|
|
19
|
+
const url = new URL(c.req.url);
|
|
20
|
+
const prefix = `/codeserver/${wsId}`;
|
|
21
|
+
let upstreamPath = url.pathname.slice(prefix.length) || '/';
|
|
22
|
+
if (!upstreamPath.startsWith('/')) {
|
|
23
|
+
upstreamPath = `/${upstreamPath}`;
|
|
24
|
+
}
|
|
25
|
+
const target = `http://127.0.0.1:${port}${upstreamPath}${url.search}`;
|
|
26
|
+
const headers = new Headers(c.req.raw.headers);
|
|
27
|
+
headers.set('host', `127.0.0.1:${port}`);
|
|
28
|
+
// Ask code-server not to compress; we re-stream the body raw.
|
|
29
|
+
headers.set('accept-encoding', 'identity');
|
|
30
|
+
const method = c.req.method;
|
|
31
|
+
const init = {
|
|
32
|
+
method,
|
|
33
|
+
headers,
|
|
34
|
+
redirect: 'manual',
|
|
35
|
+
};
|
|
36
|
+
if (method !== 'GET' && method !== 'HEAD') {
|
|
37
|
+
init.body = c.req.raw.body;
|
|
38
|
+
init.duplex = 'half';
|
|
39
|
+
}
|
|
40
|
+
let upstream;
|
|
41
|
+
try {
|
|
42
|
+
upstream = await fetch(target, init);
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
46
|
+
return c.json({ error: `code-server proxy failed: ${message}` }, 502);
|
|
47
|
+
}
|
|
48
|
+
const respHeaders = new Headers(upstream.headers);
|
|
49
|
+
for (const name of STRIP_RESPONSE_HEADERS) {
|
|
50
|
+
respHeaders.delete(name);
|
|
51
|
+
}
|
|
52
|
+
// Let code-server's service worker claim the whole subpath scope.
|
|
53
|
+
respHeaders.set('service-worker-allowed', `/codeserver/${wsId}/`);
|
|
54
|
+
return new Response(upstream.body, {
|
|
55
|
+
status: upstream.status,
|
|
56
|
+
statusText: upstream.statusText,
|
|
57
|
+
headers: respHeaders,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
export function createCodeServerRouter(codeServer) {
|
|
61
|
+
const router = new Hono();
|
|
62
|
+
// Capability/status (must be registered before the :wsId catch-all).
|
|
63
|
+
router.get('/status', async (c) => {
|
|
64
|
+
const wsId = c.req.query('wsId') || undefined;
|
|
65
|
+
return c.json(await codeServer.getStatus(wsId));
|
|
66
|
+
});
|
|
67
|
+
router.post('/stop', async (c) => {
|
|
68
|
+
const wsId = c.req.query('wsId');
|
|
69
|
+
if (wsId) {
|
|
70
|
+
await codeServer.stop(wsId);
|
|
71
|
+
}
|
|
72
|
+
return c.json(await codeServer.getStatus(wsId || undefined));
|
|
73
|
+
});
|
|
74
|
+
// Reverse-proxy everything else under /codeserver/:wsId/* to that workspace's
|
|
75
|
+
// code-server instance (prefix stripped).
|
|
76
|
+
router.all('/:wsId/*', (c) => proxy(c, codeServer));
|
|
77
|
+
router.all('/:wsId', (c) => proxy(c, codeServer));
|
|
78
|
+
return router;
|
|
79
|
+
}
|
package/dist/routes/files.js
CHANGED
|
@@ -186,6 +186,78 @@ export function createFileRouter(workspaces) {
|
|
|
186
186
|
return handleError(c, error);
|
|
187
187
|
}
|
|
188
188
|
});
|
|
189
|
+
// Move / rename a file or directory within the workspace (Drive UI).
|
|
190
|
+
router.post('/move', async (c) => {
|
|
191
|
+
try {
|
|
192
|
+
const body = await readJson(c);
|
|
193
|
+
const workspaceId = body?.workspaceId;
|
|
194
|
+
if (!workspaceId) {
|
|
195
|
+
throw createHttpError('workspaceId is required', 400);
|
|
196
|
+
}
|
|
197
|
+
if (!body?.from || !body?.to) {
|
|
198
|
+
throw createHttpError('from and to are required', 400);
|
|
199
|
+
}
|
|
200
|
+
const workspace = requireWorkspace(workspaces, workspaceId);
|
|
201
|
+
const fromPath = await resolveSafePath(workspace.path, body.from);
|
|
202
|
+
const toPath = await resolveSafePath(workspace.path, body.to);
|
|
203
|
+
// Source must exist (throws ENOENT otherwise).
|
|
204
|
+
await fs.stat(fromPath);
|
|
205
|
+
// No-clobber unless the caller explicitly opts in.
|
|
206
|
+
if (!body.overwrite) {
|
|
207
|
+
try {
|
|
208
|
+
await fs.access(toPath);
|
|
209
|
+
throw createHttpError('Destination already exists', 409);
|
|
210
|
+
}
|
|
211
|
+
catch (err) {
|
|
212
|
+
if (err.code !== 'ENOENT') {
|
|
213
|
+
throw err;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
await fs.mkdir(path.dirname(toPath), { recursive: true });
|
|
218
|
+
await fs.rename(fromPath, toPath);
|
|
219
|
+
return c.json({ from: body.from, to: body.to, moved: true });
|
|
220
|
+
}
|
|
221
|
+
catch (error) {
|
|
222
|
+
return handleError(c, error);
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
// Upload one or more files into a workspace directory (multipart form). The
|
|
226
|
+
// form field `file` may repeat. Filenames are reduced to their basename so an
|
|
227
|
+
// upload can never escape the target directory.
|
|
228
|
+
router.post('/upload', async (c) => {
|
|
229
|
+
try {
|
|
230
|
+
const form = await c.req.parseBody({ all: true });
|
|
231
|
+
const workspaceId = typeof form.workspaceId === 'string' ? form.workspaceId : undefined;
|
|
232
|
+
if (!workspaceId) {
|
|
233
|
+
throw createHttpError('workspaceId is required', 400);
|
|
234
|
+
}
|
|
235
|
+
const dir = typeof form.path === 'string' ? form.path : '';
|
|
236
|
+
const workspace = requireWorkspace(workspaces, workspaceId);
|
|
237
|
+
const field = form.file;
|
|
238
|
+
const files = (Array.isArray(field) ? field : [field]).filter((item) => item instanceof File);
|
|
239
|
+
if (files.length === 0) {
|
|
240
|
+
throw createHttpError('no files provided', 400);
|
|
241
|
+
}
|
|
242
|
+
const uploaded = [];
|
|
243
|
+
for (const file of files) {
|
|
244
|
+
if (file.size > MAX_FILE_SIZE) {
|
|
245
|
+
throw createHttpError(`File too large: ${file.name} (${Math.round(file.size / 1024 / 1024)}MB). Maximum is ${Math.round(MAX_FILE_SIZE / 1024 / 1024)}MB.`, 413);
|
|
246
|
+
}
|
|
247
|
+
const safeName = path.basename(file.name);
|
|
248
|
+
const rel = dir ? `${dir}/${safeName}` : safeName;
|
|
249
|
+
const target = await resolveSafePath(workspace.path, rel);
|
|
250
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
251
|
+
const buffer = Buffer.from(await file.arrayBuffer());
|
|
252
|
+
await fs.writeFile(target, buffer);
|
|
253
|
+
uploaded.push(rel);
|
|
254
|
+
}
|
|
255
|
+
return c.json({ uploaded });
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
return handleError(c, error);
|
|
259
|
+
}
|
|
260
|
+
});
|
|
189
261
|
// Delete directory
|
|
190
262
|
router.delete('/dir', async (c) => {
|
|
191
263
|
try {
|
package/dist/server.js
CHANGED
|
@@ -7,7 +7,7 @@ import { serve } from '@hono/node-server';
|
|
|
7
7
|
import { serveStatic } from '@hono/node-server/serve-static';
|
|
8
8
|
import { bodyLimit } from 'hono/body-limit';
|
|
9
9
|
import { DatabaseSync } from 'node:sqlite';
|
|
10
|
-
import { PORT, HOST, NODE_ENV, BASIC_AUTH_USER, BASIC_AUTH_PASSWORD, CORS_ORIGIN, MAX_FILE_SIZE, MAX_REQUEST_BODY_SIZE, TRUST_PROXY, hasStatic, distDir, dbPath, } from './config.js';
|
|
10
|
+
import { PORT, HOST, NODE_ENV, BASIC_AUTH_USER, BASIC_AUTH_PASSWORD, CORS_ORIGIN, MAX_FILE_SIZE, MAX_REQUEST_BODY_SIZE, TRUST_PROXY, hasStatic, distDir, dbPath, AGENT_BROWSER_REAP_MCP, AGENT_BROWSER_CDP_PORT, AGENT_BROWSER_MAX_MCP_PER_AGENT, AGENT_BROWSER_MCP_IDLE_MS, } from './config.js';
|
|
11
11
|
import { securityHeaders } from './middleware/security.js';
|
|
12
12
|
import { corsMiddleware } from './middleware/cors.js';
|
|
13
13
|
import { basicAuthMiddleware, generateWsToken, isBasicAuthEnabled } from './middleware/auth.js';
|
|
@@ -20,7 +20,10 @@ import { createGitRouter } from './routes/git.js';
|
|
|
20
20
|
import { createSettingsRouter } from './routes/settings.js';
|
|
21
21
|
import { createMcpRouter } from './routes/mcp.js';
|
|
22
22
|
import { createBrowserRouter } from './routes/browser.js';
|
|
23
|
+
import { createCodeServerRouter } from './routes/codeserver.js';
|
|
23
24
|
import { AgentBrowserService } from './utils/agent-browser.js';
|
|
25
|
+
import { CodeServerService } from './utils/code-server.js';
|
|
26
|
+
import { startMcpReaper } from './utils/browser-maintenance.js';
|
|
24
27
|
import { setupWebSocketServer, getConnectionLimit, setConnectionLimit, getConnectionStats, clearAllConnections, } from './websocket.js';
|
|
25
28
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
26
29
|
// Request ID and logging middleware
|
|
@@ -51,6 +54,7 @@ export async function createServer() {
|
|
|
51
54
|
const decks = new Map();
|
|
52
55
|
const terminals = new Map();
|
|
53
56
|
const browserService = new AgentBrowserService();
|
|
57
|
+
const codeServerService = new CodeServerService(workspaces);
|
|
54
58
|
// 共有ブラウザを起動時から常駐させ、BrowserPane を開いていなくても
|
|
55
59
|
// Codex/Claude の chrome MCP(--browserUrl 127.0.0.1:9222)が常に使えるようにする。
|
|
56
60
|
// 失敗してもサーバ起動は継続(Chrome 未導入の環境などを考慮)。AGENT_BROWSER_AUTOSTART=false で無効化可。
|
|
@@ -59,6 +63,17 @@ export async function createServer() {
|
|
|
59
63
|
console.error('[browser] 共有ブラウザの自動起動に失敗:', err instanceof Error ? err.message : err);
|
|
60
64
|
});
|
|
61
65
|
}
|
|
66
|
+
// Periodically reap leaked chrome-devtools-mcp servers (agents spawn one per
|
|
67
|
+
// session against our CDP port and can leave them disconnected — wedging the
|
|
68
|
+
// agent's chrome tools and leaking GBs). Safe: only disconnected + idle ones.
|
|
69
|
+
const stopMcpReaper = AGENT_BROWSER_REAP_MCP
|
|
70
|
+
? startMcpReaper({
|
|
71
|
+
cdpPort: AGENT_BROWSER_CDP_PORT,
|
|
72
|
+
maxPerAgent: AGENT_BROWSER_MAX_MCP_PER_AGENT,
|
|
73
|
+
idleMs: AGENT_BROWSER_MCP_IDLE_MS,
|
|
74
|
+
intervalMs: 120_000,
|
|
75
|
+
})
|
|
76
|
+
: null;
|
|
62
77
|
loadPersistedState(db, workspaces, workspacePathIndex, decks);
|
|
63
78
|
// Create Hono app
|
|
64
79
|
const app = new Hono();
|
|
@@ -80,6 +95,8 @@ export async function createServer() {
|
|
|
80
95
|
if (basicAuthMiddleware) {
|
|
81
96
|
app.use('/api/*', basicAuthMiddleware);
|
|
82
97
|
app.use('/oauth/authorize', basicAuthMiddleware);
|
|
98
|
+
// code-server runs with --auth none; this same-origin proxy is the gate.
|
|
99
|
+
app.use('/codeserver/*', basicAuthMiddleware);
|
|
83
100
|
}
|
|
84
101
|
app.get('/health', (c) => c.json({ status: 'ok', timestamp: new Date().toISOString(), uptime: process.uptime() }));
|
|
85
102
|
// Mount routers
|
|
@@ -90,6 +107,7 @@ export async function createServer() {
|
|
|
90
107
|
app.route('/api/terminals', terminalRouter);
|
|
91
108
|
app.route('/api/git', createGitRouter(workspaces));
|
|
92
109
|
app.route('/api/browser', createBrowserRouter(browserService));
|
|
110
|
+
app.route('/codeserver', createCodeServerRouter(codeServerService));
|
|
93
111
|
app.get('/api/config', getConfigHandler());
|
|
94
112
|
app.get('/api/ws-token', (c) => c.json({ token: generateWsToken(), authEnabled: isBasicAuthEnabled() }));
|
|
95
113
|
app.get('/api/ws/stats', (c) => c.json({ limit: getConnectionLimit(), connections: getConnectionStats() }));
|
|
@@ -122,7 +140,7 @@ export async function createServer() {
|
|
|
122
140
|
});
|
|
123
141
|
}
|
|
124
142
|
const server = serve({ fetch: app.fetch, port: PORT, hostname: HOST });
|
|
125
|
-
setupWebSocketServer(server, terminals, browserService);
|
|
143
|
+
setupWebSocketServer(server, terminals, browserService, codeServerService);
|
|
126
144
|
server.on('listening', () => {
|
|
127
145
|
const baseUrl = `http://localhost:${PORT}`;
|
|
128
146
|
console.log(`Deck IDE server listening on ${baseUrl}`);
|
|
@@ -157,7 +175,9 @@ export async function createServer() {
|
|
|
157
175
|
session.kill();
|
|
158
176
|
});
|
|
159
177
|
terminals.clear();
|
|
178
|
+
stopMcpReaper?.();
|
|
160
179
|
await browserService.stop();
|
|
180
|
+
await codeServerService.stopAll();
|
|
161
181
|
try {
|
|
162
182
|
db.close();
|
|
163
183
|
}
|
|
@@ -5,6 +5,7 @@ import os from 'node:os';
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { WebSocket } from 'ws';
|
|
7
7
|
import { BrowserAudioRelay } from './browser-audio.js';
|
|
8
|
+
import { BrowserWebRtcRelay } from './browser-webrtc.js';
|
|
8
9
|
const DEFAULT_PROFILE_DIR = path.join(os.homedir(), '.local', 'share', 'agent-browser', 'profile');
|
|
9
10
|
const DEFAULT_OUTPUT_DIR = path.join(os.homedir(), '.local', 'share', 'agent-browser', 'output');
|
|
10
11
|
const START_TIMEOUT_MS = 30_000;
|
|
@@ -320,6 +321,9 @@ export class AgentBrowserService {
|
|
|
320
321
|
viewport = { ...DEFAULT_VIEWPORT };
|
|
321
322
|
lastError;
|
|
322
323
|
audioRelay = new BrowserAudioRelay();
|
|
324
|
+
// A1 WebRTC video path (direct Xvfb capture → ffmpeg → werift). Constructed
|
|
325
|
+
// always, but inert until AGENT_BROWSER_WEBRTC is enabled and a client connects.
|
|
326
|
+
webrtcRelay = new BrowserWebRtcRelay(this);
|
|
323
327
|
profileDir = process.env.AGENT_BROWSER_PROFILE_DIR || DEFAULT_PROFILE_DIR;
|
|
324
328
|
outputDir = process.env.AGENT_BROWSER_OUTPUT_DIR || DEFAULT_OUTPUT_DIR;
|
|
325
329
|
async getStatus() {
|
|
@@ -336,6 +340,7 @@ export class AgentBrowserService {
|
|
|
336
340
|
tabs: [...this.tabs.values()].map((tab) => ({ ...tab })),
|
|
337
341
|
activeTabId: this.activeTargetId,
|
|
338
342
|
audio: await this.audioRelay.getStatus(),
|
|
343
|
+
webrtc: await this.webrtcRelay.getStatus(),
|
|
339
344
|
error: this.lastError,
|
|
340
345
|
};
|
|
341
346
|
}
|
|
@@ -421,6 +426,7 @@ export class AgentBrowserService {
|
|
|
421
426
|
}
|
|
422
427
|
this.closeLogStream();
|
|
423
428
|
await this.audioRelay.stop();
|
|
429
|
+
await this.webrtcRelay.stop();
|
|
424
430
|
await this.broadcastStatus();
|
|
425
431
|
}
|
|
426
432
|
attachWebSocket(socket) {
|
|
@@ -442,6 +448,38 @@ export class AgentBrowserService {
|
|
|
442
448
|
attachAudioWebSocket(socket) {
|
|
443
449
|
this.audioRelay.attachWebSocket(socket);
|
|
444
450
|
}
|
|
451
|
+
attachWebRtcWebSocket(socket) {
|
|
452
|
+
this.webrtcRelay.attachWebSocket(socket);
|
|
453
|
+
}
|
|
454
|
+
// --- WebRtcHost: capabilities the WebRTC relay needs from the browser ---
|
|
455
|
+
isBrowserRunning() {
|
|
456
|
+
return this.isRunning();
|
|
457
|
+
}
|
|
458
|
+
// Position+size the active tab's OS window on the X display. No window manager
|
|
459
|
+
// runs under Xvfb, so 'maximized'/'fullscreen' window states are unreliable —
|
|
460
|
+
// explicit normal bounds are not.
|
|
461
|
+
async setActiveWindowBounds(left, top, width, height) {
|
|
462
|
+
if (!this.activeTargetId) {
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
await this.connectBrowserCdp();
|
|
466
|
+
const cdp = this.browserCdp;
|
|
467
|
+
if (!cdp) {
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
const win = await cdp
|
|
471
|
+
.send('Browser.getWindowForTarget', { targetId: this.activeTargetId })
|
|
472
|
+
.catch(() => null);
|
|
473
|
+
if (!win || typeof win.windowId !== 'number') {
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
await cdp
|
|
477
|
+
.send('Browser.setWindowBounds', {
|
|
478
|
+
windowId: win.windowId,
|
|
479
|
+
bounds: { left, top, width, height, windowState: 'normal' },
|
|
480
|
+
})
|
|
481
|
+
.catch(() => undefined);
|
|
482
|
+
}
|
|
445
483
|
async navigate(input) {
|
|
446
484
|
const url = normalizeBrowserUrl(input);
|
|
447
485
|
await this.start();
|
|
@@ -554,6 +592,13 @@ export class AgentBrowserService {
|
|
|
554
592
|
// GPU を browser プロセス内で動かし、GPU サンドボックスを外して回避する。
|
|
555
593
|
'--in-process-gpu',
|
|
556
594
|
'--disable-gpu-sandbox',
|
|
595
|
+
// GPU レスのサーバでも WebGL を「本物のソフトウェアレンダラ(SwiftShader/ANGLE)」として
|
|
596
|
+
// 実際に動かす。Chrome >=130 は自動 SwiftShader フォールバックを廃止したため
|
|
597
|
+
// --enable-unsafe-swiftshader が必須。WebGL が全く無いと実Chromeとして不自然で
|
|
598
|
+
// ボット判定(Cloudflare Turnstile 等)の信号になる。実機検証で WebGL 復活+無クラッシュを確認済み。
|
|
599
|
+
'--use-gl=angle',
|
|
600
|
+
'--use-angle=swiftshader',
|
|
601
|
+
'--enable-unsafe-swiftshader',
|
|
557
602
|
// ボット判定(Cloudflare の Managed Challenge 等)で誤検知されにくくする。
|
|
558
603
|
// navigator.webdriver を立てる自動化フラグを無効化する。残りの偽装は CDP 側。
|
|
559
604
|
'--disable-blink-features=AutomationControlled',
|
|
@@ -846,9 +891,13 @@ export class AgentBrowserService {
|
|
|
846
891
|
}
|
|
847
892
|
const major = (userAgent.match(/Chrome\/(\d+)/) || [])[1] || '120';
|
|
848
893
|
if (userAgent) {
|
|
894
|
+
// acceptLanguage は q 値を付けない素のカンマ区切りにする。q 値を含めると
|
|
895
|
+
// Chrome がさらに q を付与して "ja;q=0.9;q=0.9" のような実ブラウザが出さない
|
|
896
|
+
// 不正なヘッダになり、それ自体がボット信号になる。
|
|
897
|
+
const fullVersion = (userAgent.match(/Chrome\/([\d.]+)/) || [])[1] || `${major}.0.0.0`;
|
|
849
898
|
await cdp.send('Emulation.setUserAgentOverride', {
|
|
850
899
|
userAgent,
|
|
851
|
-
acceptLanguage: 'ja-JP,ja
|
|
900
|
+
acceptLanguage: 'ja-JP,ja,en-US,en',
|
|
852
901
|
platform: 'Linux x86_64',
|
|
853
902
|
userAgentMetadata: {
|
|
854
903
|
brands: [
|
|
@@ -856,20 +905,31 @@ export class AgentBrowserService {
|
|
|
856
905
|
{ brand: 'Chromium', version: major },
|
|
857
906
|
{ brand: 'Google Chrome', version: major },
|
|
858
907
|
],
|
|
859
|
-
|
|
908
|
+
fullVersionList: [
|
|
909
|
+
{ brand: 'Not_A Brand', version: '24.0.0.0' },
|
|
910
|
+
{ brand: 'Chromium', version: fullVersion },
|
|
911
|
+
{ brand: 'Google Chrome', version: fullVersion },
|
|
912
|
+
],
|
|
860
913
|
platform: 'Linux',
|
|
861
914
|
platformVersion: '',
|
|
862
915
|
architecture: 'x86',
|
|
916
|
+
bitness: '64',
|
|
917
|
+
wow64: false,
|
|
863
918
|
model: '',
|
|
864
919
|
mobile: false,
|
|
865
920
|
},
|
|
866
921
|
});
|
|
867
922
|
}
|
|
923
|
+
// 反検知は「正直で整合の取れた実Chrome」に寄せるのが正攻法。
|
|
924
|
+
// navigator.languages を Accept-Language と一致させる程度に留め、
|
|
925
|
+
// plugins や WebGL ベンダの「偽装」はしない(偽装は逆に不整合の検知信号になる)。
|
|
926
|
+
// navigator.webdriver は起動フラグ --disable-blink-features=AutomationControlled で
|
|
927
|
+
// 既に false 化されているが、保険として undefined を維持する。WebGL は実フラグ
|
|
928
|
+
// (--use-angle=swiftshader 等) で実際に動作するので、レンダラは正直に SwiftShader を返す。
|
|
868
929
|
const stealthSource = `
|
|
869
930
|
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
|
|
870
931
|
if (!window.chrome) { window.chrome = { runtime: {} }; }
|
|
871
932
|
Object.defineProperty(navigator, 'languages', { get: () => ['ja-JP', 'ja', 'en-US', 'en'] });
|
|
872
|
-
Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] });
|
|
873
933
|
try {
|
|
874
934
|
const _q = window.navigator.permissions && window.navigator.permissions.query;
|
|
875
935
|
if (_q) {
|
|
@@ -879,14 +939,6 @@ export class AgentBrowserService {
|
|
|
879
939
|
: _q(p);
|
|
880
940
|
}
|
|
881
941
|
} catch (e) { /* ignore */ }
|
|
882
|
-
try {
|
|
883
|
-
const getParam = WebGLRenderingContext.prototype.getParameter;
|
|
884
|
-
WebGLRenderingContext.prototype.getParameter = function (p) {
|
|
885
|
-
if (p === 37445) return 'Intel Inc.';
|
|
886
|
-
if (p === 37446) return 'Intel Iris OpenGL Engine';
|
|
887
|
-
return getParam.call(this, p);
|
|
888
|
-
};
|
|
889
|
-
} catch (e) { /* ignore */ }
|
|
890
942
|
`;
|
|
891
943
|
await cdp.send('Page.addScriptToEvaluateOnNewDocument', { source: stealthSource });
|
|
892
944
|
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
// Linux clock ticks per second. Effectively always 100 on Linux; used only to
|
|
3
|
+
// estimate process age, where small drift is harmless.
|
|
4
|
+
const CLOCK_HZ = 100;
|
|
5
|
+
/**
|
|
6
|
+
* Pure selection: given the set of chrome-devtools-mcp processes (node servers +
|
|
7
|
+
* their npm/sh launcher wrappers), return the pids to kill. Exported for testing.
|
|
8
|
+
*
|
|
9
|
+
* A "stack top" is a proc whose parent is not itself in the set (its parent is
|
|
10
|
+
* the agent). Stacks are grouped by agent; for any agent owning more than
|
|
11
|
+
* `maxPerAgent` stacks, the oldest extras beyond the cap — and only those older
|
|
12
|
+
* than `idleMs` — are selected along with their whole subtree. Healthy agents
|
|
13
|
+
* (≤ maxPerAgent stacks) are never touched.
|
|
14
|
+
*/
|
|
15
|
+
export function planReap(procs, maxPerAgent, idleMs) {
|
|
16
|
+
const stacksByAgent = new Map();
|
|
17
|
+
for (const [pid, info] of procs) {
|
|
18
|
+
if (!procs.has(info.ppid)) {
|
|
19
|
+
const list = stacksByAgent.get(info.ppid) ?? [];
|
|
20
|
+
list.push(pid);
|
|
21
|
+
stacksByAgent.set(info.ppid, list);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const children = new Map();
|
|
25
|
+
for (const [pid, info] of procs) {
|
|
26
|
+
const list = children.get(info.ppid) ?? [];
|
|
27
|
+
list.push(pid);
|
|
28
|
+
children.set(info.ppid, list);
|
|
29
|
+
}
|
|
30
|
+
const kill = [];
|
|
31
|
+
for (const tops of stacksByAgent.values()) {
|
|
32
|
+
if (tops.length <= maxPerAgent) {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
tops.sort((a, b) => procs.get(b).ageSec - procs.get(a).ageSec); // oldest first
|
|
36
|
+
for (const top of tops.slice(0, tops.length - maxPerAgent)) {
|
|
37
|
+
if (procs.get(top).ageSec * 1000 < idleMs) {
|
|
38
|
+
continue; // spare recent stacks (e.g. mid-restart)
|
|
39
|
+
}
|
|
40
|
+
const queue = [top];
|
|
41
|
+
while (queue.length) {
|
|
42
|
+
const pid = queue.shift();
|
|
43
|
+
kill.push(pid);
|
|
44
|
+
for (const child of children.get(pid) ?? []) {
|
|
45
|
+
if (procs.has(child))
|
|
46
|
+
queue.push(child);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return kill;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Reaps leaked `chrome-devtools-mcp` server stacks.
|
|
55
|
+
*
|
|
56
|
+
* Agents (Claude/Codex) spawn one chrome-devtools-mcp server per session pointed
|
|
57
|
+
* at our shared browser's CDP port. A buggy agent can respawn it on every failure
|
|
58
|
+
* without reaping the old one — we observed a single Codex process holding 66
|
|
59
|
+
* servers (~24 GB) with the agent's active one wedged ("can't use chrome").
|
|
60
|
+
*
|
|
61
|
+
* NOTE: chrome-devtools-mcp connects to the browser lazily (only during a tool
|
|
62
|
+
* call), so "not connected to 9222" does NOT mean "dead" — an idle-but-live
|
|
63
|
+
* session would look disconnected. We therefore key off the actual leak
|
|
64
|
+
* signature instead: when ONE agent process owns more than `maxPerAgent`
|
|
65
|
+
* chrome-devtools-mcp stacks (aimed at our port), the oldest extras beyond the
|
|
66
|
+
* cap that are also older than `idleMs` are killed, keeping the newest (active)
|
|
67
|
+
* ones. A healthy agent with a single MCP is never touched. Linux-only.
|
|
68
|
+
*/
|
|
69
|
+
export function startMcpReaper(opts) {
|
|
70
|
+
const timer = setInterval(() => {
|
|
71
|
+
void reapOnce(opts).catch(() => undefined);
|
|
72
|
+
}, opts.intervalMs);
|
|
73
|
+
timer.unref?.();
|
|
74
|
+
return () => clearInterval(timer);
|
|
75
|
+
}
|
|
76
|
+
// All chrome-devtools-mcp processes (node servers + npm/sh launcher wrappers)
|
|
77
|
+
// whose cmdline targets our CDP port. Keyed by pid.
|
|
78
|
+
async function readMcpProcs(cdpPort) {
|
|
79
|
+
const procs = new Map();
|
|
80
|
+
let uptime;
|
|
81
|
+
try {
|
|
82
|
+
uptime = parseFloat((await fs.readFile('/proc/uptime', 'utf8')).split(' ')[0]);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return procs; // not Linux / no procfs
|
|
86
|
+
}
|
|
87
|
+
let pids;
|
|
88
|
+
try {
|
|
89
|
+
pids = (await fs.readdir('/proc')).filter((n) => /^\d+$/.test(n));
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return procs;
|
|
93
|
+
}
|
|
94
|
+
const portToken = `127.0.0.1:${cdpPort}`;
|
|
95
|
+
for (const pid of pids) {
|
|
96
|
+
let cmdline;
|
|
97
|
+
try {
|
|
98
|
+
cmdline = (await fs.readFile(`/proc/${pid}/cmdline`, 'utf8')).replace(/\0/g, ' ').trim();
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
// Target only chrome-devtools-mcp aimed at OUR shared browser.
|
|
104
|
+
if (!cmdline.includes('chrome-devtools-mcp') || !cmdline.includes(portToken)) {
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
let stat;
|
|
108
|
+
try {
|
|
109
|
+
stat = await fs.readFile(`/proc/${pid}/stat`, 'utf8');
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
// comm (field 2) is parenthesised and may contain spaces/parens — parse after
|
|
115
|
+
// the final ')'. Then index 1 = ppid (field 4), index 19 = starttime (field 22).
|
|
116
|
+
const after = stat.slice(stat.lastIndexOf(')') + 2).split(' ');
|
|
117
|
+
const ppid = Number.parseInt(after[1], 10);
|
|
118
|
+
const starttime = Number.parseInt(after[19], 10);
|
|
119
|
+
if (!Number.isFinite(ppid) || !Number.isFinite(starttime)) {
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
procs.set(Number.parseInt(pid, 10), { ppid, cmdline, ageSec: uptime - starttime / CLOCK_HZ });
|
|
123
|
+
}
|
|
124
|
+
return procs;
|
|
125
|
+
}
|
|
126
|
+
async function reapOnce(opts) {
|
|
127
|
+
const procs = await readMcpProcs(opts.cdpPort);
|
|
128
|
+
if (procs.size === 0) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const kill = planReap(procs, opts.maxPerAgent, opts.idleMs);
|
|
132
|
+
let reaped = 0;
|
|
133
|
+
for (const pid of kill) {
|
|
134
|
+
try {
|
|
135
|
+
process.kill(pid, 'SIGTERM');
|
|
136
|
+
reaped++;
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
// already gone
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (reaped > 0) {
|
|
143
|
+
console.log(`[browser-maintenance] reaped ${reaped} process(es) from leaked chrome-devtools-mcp stacks`);
|
|
144
|
+
}
|
|
145
|
+
}
|