deckide 3.5.41 → 3.5.43
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/README.md +28 -0
- package/bin/deckide.js +50 -1
- package/dist/config.js +28 -0
- package/dist/routes/codeserver.js +87 -0
- package/dist/routes/files.js +72 -0
- package/dist/server.js +22 -2
- package/dist/utils/agent-browser.js +60 -0
- package/dist/utils/browser-maintenance.js +145 -0
- package/dist/utils/browser-webrtc.js +493 -0
- package/dist/utils/code-server.js +360 -0
- package/dist/websocket.js +54 -2
- package/package.json +5 -2
- package/web/dist/assets/index-CSzJh3Kn.js +178 -0
- package/web/dist/assets/index-DCAuOxsd.css +32 -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/README.md
CHANGED
|
@@ -64,6 +64,11 @@ deckide config 全設定表示
|
|
|
64
64
|
deckide config set <key> <val> 設定値変更
|
|
65
65
|
deckide config get <key> 設定値取得
|
|
66
66
|
deckide config reset 設定リセット
|
|
67
|
+
|
|
68
|
+
deckide codeserver install code-server(VS Code エディタ)を取得/インストール
|
|
69
|
+
deckide codeserver status code-server のインストール状態
|
|
70
|
+
deckide codeserver path code-server バイナリのパスを表示
|
|
71
|
+
deckide codeserver uninstall 取得済み code-server を削除
|
|
67
72
|
```
|
|
68
73
|
|
|
69
74
|
### 起動オプション
|
|
@@ -167,6 +172,29 @@ ide/
|
|
|
167
172
|
| `AGENT_BROWSER_FFMPEG` | ffmpeg 実行ファイル | PATH から自動検出 |
|
|
168
173
|
| `AGENT_BROWSER_PACTL` | pactl 実行ファイル | PATH から自動検出 |
|
|
169
174
|
| `AGENT_BROWSER_AUDIO_SOURCE` | 音声キャプチャ元 | 既定 sink の monitor source |
|
|
175
|
+
| `AGENT_BROWSER_WEBRTC` | エージェントブラウザ映像を WebRTC 配信(`true`/`auto`/`false`、現状はオプトイン) | false |
|
|
176
|
+
| `AGENT_BROWSER_WEBRTC_BITRATE` | WebRTC ビットレート(ffmpeg `-b:v` 形式) | 3M |
|
|
177
|
+
| `AGENT_BROWSER_WEBRTC_FPS` | WebRTC フレームレート | 30 |
|
|
178
|
+
| `AGENT_BROWSER_WEBRTC_ICE_SERVERS` | ICE サーバ(JSON配列)。空ならホスト候補のみ(同一ホスト/LAN) | — |
|
|
179
|
+
| `AGENT_BROWSER_DISPLAY` | キャプチャする X ディスプレイ(未設定なら Chrome から自動検出) | — |
|
|
180
|
+
| `AGENT_BROWSER_CDP_PORT` | 共有ブラウザの CDP ポート | 9222 |
|
|
181
|
+
| `AGENT_BROWSER_REAP_MCP` | リークした chrome-devtools-mcp スタックを自動回収 | true |
|
|
182
|
+
| `AGENT_BROWSER_MAX_MCP_PER_AGENT` | 1エージェントが保持できる MCP スタック上限(超過分の古いものを回収) | 3 |
|
|
183
|
+
| `AGENT_BROWSER_MCP_IDLE_MS` | 回収対象とみなすアイドル閾値 (ms) | 600000 |
|
|
184
|
+
| `DECKIDE_CODE_SERVER_BIN` | code-server バイナリ(未設定なら ~/.deckide → PATH → 初回自動DL) | — |
|
|
185
|
+
| `DECKIDE_CODE_SERVER_VERSION` | 取得する code-server バージョン | 4.125.0 |
|
|
186
|
+
|
|
187
|
+
## Deck Drive シェルと code-server
|
|
188
|
+
|
|
189
|
+
既定の UI は **Deck Drive**(Google Drive 風のファイル/ワークスペース・ブラウザ)です。ファイルを開くと **code-server(ブラウザ版 VS Code)** が同一オリジンのリバースプロキシ(`/codeserver/:wsId/*`)越しに起動します。ターミナルデッキ・エージェントブラウザもタブとして同じシェルに統合されています。
|
|
190
|
+
|
|
191
|
+
- **code-server の取得**: 初回利用時に固定版(`DECKIDE_CODE_SERVER_VERSION`、既定 4.125.0)を `~/.deckide/code-server` へ自動ダウンロードします。手動なら `deckide codeserver install`。
|
|
192
|
+
- **拡張機能**: Microsoft マーケットプレイスは利用不可(Open VSX のみ)。Pylance/Copilot/Remote-* は使えません(Pyright 等で代替)。
|
|
193
|
+
- **リソース**: code-server はワークスペース毎に1プロセス(目安 +1GB RAM / +2 コア)。アイドル20分で自動停止します。
|
|
194
|
+
- **認証**: code-server は `--auth none` で起動し、Deck IDE の Basic 認証配下の同一オリジンプロキシで保護します。
|
|
195
|
+
- **旧 UI**: 従来の Monaco/分割ターミナル・シェルは `?shell=legacy` で引き続き利用できます。
|
|
196
|
+
|
|
197
|
+
> エージェントブラウザの WebRTC 配信(`AGENT_BROWSER_WEBRTC` / `?webrtc=1`)は実験的なオプトインです。既定は従来の JPEG ストリーミングです。音声トラックは PulseAudio がある環境でのみ動作します。
|
|
170
198
|
|
|
171
199
|
## MCP / OAuth
|
|
172
200
|
|
package/bin/deckide.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
1
|
+
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import path from 'node:path';
|
|
@@ -172,6 +172,11 @@ Usage:
|
|
|
172
172
|
deckide config get <key> Get a config value
|
|
173
173
|
deckide config reset Reset all settings
|
|
174
174
|
|
|
175
|
+
deckide codeserver install Download/install code-server (the VS Code editor)
|
|
176
|
+
deckide codeserver status Show code-server install status
|
|
177
|
+
deckide codeserver path Print the code-server binary path
|
|
178
|
+
deckide codeserver uninstall Remove the downloaded code-server
|
|
179
|
+
|
|
175
180
|
Options (for start):
|
|
176
181
|
-p, --port <port> Port (default: 8787)
|
|
177
182
|
--host <host> Host (default: 0.0.0.0)
|
|
@@ -338,6 +343,50 @@ if (command === 'auth') {
|
|
|
338
343
|
}
|
|
339
344
|
|
|
340
345
|
// ── deckide status ──
|
|
346
|
+
// ── deckide codeserver ──
|
|
347
|
+
if (command === 'codeserver') {
|
|
348
|
+
const sub = args[1] || 'status';
|
|
349
|
+
process.env.DECKIDE_DATA_DIR = dataDir;
|
|
350
|
+
const modPath = path.join(__dirname, '..', 'dist', 'utils', 'code-server.js');
|
|
351
|
+
let CodeServerService;
|
|
352
|
+
try {
|
|
353
|
+
({ CodeServerService } = await import(modPath));
|
|
354
|
+
} catch (e) {
|
|
355
|
+
console.error(`code-server module not found — is Deck IDE built? (${e.message})`);
|
|
356
|
+
process.exit(1);
|
|
357
|
+
}
|
|
358
|
+
const cs = new CodeServerService(new Map());
|
|
359
|
+
if (sub === 'install') {
|
|
360
|
+
try {
|
|
361
|
+
const bin = await cs.download((m) => console.log(` ${m}`));
|
|
362
|
+
console.log(`\x1b[32mInstalled\x1b[0m → ${bin}`);
|
|
363
|
+
process.exit(0);
|
|
364
|
+
} catch (e) {
|
|
365
|
+
console.error(`\x1b[31mInstall failed:\x1b[0m ${e.message}`);
|
|
366
|
+
process.exit(1);
|
|
367
|
+
}
|
|
368
|
+
} else if (sub === 'status') {
|
|
369
|
+
const s = await cs.getStatus();
|
|
370
|
+
console.log('code-server');
|
|
371
|
+
console.log(` acquired: ${s.acquired}`);
|
|
372
|
+
console.log(` binPath: ${s.binPath || '(none)'}`);
|
|
373
|
+
if (s.reason) console.log(` note: ${s.reason}`);
|
|
374
|
+
process.exit(0);
|
|
375
|
+
} else if (sub === 'path') {
|
|
376
|
+
const bin = await cs.resolveBin();
|
|
377
|
+
if (bin) { console.log(bin); process.exit(0); }
|
|
378
|
+
console.error('code-server is not installed (run: deckide codeserver install)');
|
|
379
|
+
process.exit(1);
|
|
380
|
+
} else if (sub === 'uninstall') {
|
|
381
|
+
await cs.uninstall();
|
|
382
|
+
console.log('Uninstalled code-server.');
|
|
383
|
+
process.exit(0);
|
|
384
|
+
} else {
|
|
385
|
+
console.error('usage: deckide codeserver install|status|path|uninstall');
|
|
386
|
+
process.exit(1);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
341
390
|
if (command === 'status') {
|
|
342
391
|
const settings = loadSettings();
|
|
343
392
|
const port = settings.port || 8787;
|
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,87 @@
|
|
|
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
|
+
// Kick off the (single-flight) code-server download without blocking — the
|
|
75
|
+
// client polls /status until acquired. No-op if already installed.
|
|
76
|
+
router.post('/install', async (c) => {
|
|
77
|
+
if (!(await codeServer.isAcquired())) {
|
|
78
|
+
void codeServer.download().catch(() => undefined);
|
|
79
|
+
}
|
|
80
|
+
return c.json(await codeServer.getStatus());
|
|
81
|
+
});
|
|
82
|
+
// Reverse-proxy everything else under /codeserver/:wsId/* to that workspace's
|
|
83
|
+
// code-server instance (prefix stripped).
|
|
84
|
+
router.all('/:wsId/*', (c) => proxy(c, codeServer));
|
|
85
|
+
router.all('/:wsId', (c) => proxy(c, codeServer));
|
|
86
|
+
return router;
|
|
87
|
+
}
|
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,57 @@ 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
|
+
}
|
|
483
|
+
// Pin the active tab's render viewport to a fixed size (the WebRTC framebuffer)
|
|
484
|
+
// so captured pixels equal page coordinates. Does not mutate this.viewport, so
|
|
485
|
+
// the JPEG path's client-driven resize is unaffected when WebRTC isn't active.
|
|
486
|
+
async pinViewport(width, height) {
|
|
487
|
+
await this.ensureActiveCdp();
|
|
488
|
+
if (!this.activeCdp) {
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
await this.activeCdp
|
|
492
|
+
.send('Emulation.setDeviceMetricsOverride', {
|
|
493
|
+
width,
|
|
494
|
+
height,
|
|
495
|
+
deviceScaleFactor: 1,
|
|
496
|
+
mobile: false,
|
|
497
|
+
screenWidth: width,
|
|
498
|
+
screenHeight: height,
|
|
499
|
+
})
|
|
500
|
+
.catch(() => undefined);
|
|
501
|
+
}
|
|
445
502
|
async navigate(input) {
|
|
446
503
|
const url = normalizeBrowserUrl(input);
|
|
447
504
|
await this.start();
|
|
@@ -585,6 +642,9 @@ export class AgentBrowserService {
|
|
|
585
642
|
this.port = null;
|
|
586
643
|
this.disconnectActiveCdp();
|
|
587
644
|
this.closeBrowserCdp();
|
|
645
|
+
// Tear down the WebRTC relay too, so a Chrome crash never leaves an
|
|
646
|
+
// orphaned x11grab/ffmpeg encoder pointed at a dead display.
|
|
647
|
+
void this.webrtcRelay.stop();
|
|
588
648
|
this.tabs.clear();
|
|
589
649
|
this.activeTargetId = null;
|
|
590
650
|
this.ready = false;
|
|
@@ -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
|
+
}
|