ai-cli-online 2.6.0 → 2.9.0
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 +6 -6
- package/README.zh-CN.md +6 -6
- package/package.json +1 -1
- package/server/dist/db.js +3 -0
- package/server/dist/files.d.ts +5 -0
- package/server/dist/files.js +46 -8
- package/server/dist/index.js +32 -5
- package/server/dist/websocket.js +40 -6
- package/server/package.json +1 -1
- package/shared/package.json +1 -1
- package/web/dist/assets/index-BdGwxTX2.js +31 -0
- package/web/dist/assets/index-CuElHGZV.css +32 -0
- package/web/dist/assets/markdown-CU76q5qk.js +60 -0
- package/web/dist/index.html +6 -3
- package/web/package.json +1 -1
- package/web/dist/assets/index-CmWFgqt3.js +0 -31
- package/web/dist/assets/index-CtLiFTts.css +0 -32
- package/web/dist/assets/markdown-BERZKN_L.js +0 -60
package/README.md
CHANGED
|
@@ -20,18 +20,19 @@ Ideal for **unstable networks** where SSH drops frequently, or as a **local stat
|
|
|
20
20
|
- **Session Persistence** — tmux keeps processes alive through disconnects; fixed socket path ensures auto-reconnect even after server restarts
|
|
21
21
|
- **Multi-Tab** — independent terminal groups with layout persistence across browser refreshes
|
|
22
22
|
- **Split Panes** — horizontal / vertical splits, arbitrarily nested
|
|
23
|
-
- **Document Browser** — view Markdown, HTML, and PDF files alongside your terminal; file picker shows file sizes
|
|
24
23
|
- **Mermaid Diagrams** — Gantt charts, flowcharts, and other Mermaid diagrams render inline with dark theme; CDN fallback for reliability
|
|
25
|
-
- **Plan Annotations** —
|
|
24
|
+
- **Plan Annotations** — 4 annotation types (insert / delete / replace / comment) on document content with persistent storage; selection float button group for quick action
|
|
26
25
|
- **Editor Panel** — multi-line editing with server-side draft persistence (SQLite), undo stack, and slash commands (`/history`, etc.)
|
|
27
26
|
- **Copy & Paste** — mouse selection auto-copies to clipboard; right-click pastes into terminal
|
|
28
27
|
- **File Transfer** — upload files to CWD, browse and download via REST API
|
|
29
28
|
- **Scroll History** — capture-pane scrollback viewer with ANSI color preservation
|
|
30
29
|
- **Session Management** — sidebar to restore, delete, rename sessions, and close individual terminals (with confirmation)
|
|
30
|
+
- **CJK Font Support** — LXGW WenKai Mono via CDN with unicode-range subsetting; browser loads only needed CJK glyphs on demand
|
|
31
31
|
- **Font Size Control** — adjustable terminal font size (A−/A+) with server-side persistence
|
|
32
32
|
- **Network Indicator** — real-time RTT latency display with signal bars
|
|
33
33
|
- **Auto Reconnect** — exponential backoff with jitter to prevent thundering herd
|
|
34
34
|
- **Secure Auth** — token authentication with timing-safe comparison
|
|
35
|
+
- **Security Hardening** — symlink traversal protection, unauthenticated WebSocket connection limits, TOCTOU download guard, CSP headers (frame-ancestors, base-uri, form-action)
|
|
35
36
|
|
|
36
37
|
## Comparison: AI-Cli Online vs OpenClaw
|
|
37
38
|
|
|
@@ -46,14 +47,14 @@ Ideal for **unstable networks** where SSH drops frequently, or as a **local stat
|
|
|
46
47
|
| **Native Apps** | None (pure web) | macOS + iOS + Android |
|
|
47
48
|
| **Voice Interaction** | None | Voice Wake + Talk Mode |
|
|
48
49
|
| **AI Agent** | None built-in (run any CLI) | Pi Agent runtime + multi-agent routing |
|
|
49
|
-
| **Canvas / UI** |
|
|
50
|
+
| **Canvas / UI** | Plan annotations (Markdown) | A2UI real-time visual workspace |
|
|
50
51
|
| **File Transfer** | REST API upload / download | Channel-native media |
|
|
51
52
|
| **Security Model** | Token auth + timing-safe | Device pairing + DM policy + Docker sandbox |
|
|
52
53
|
| **Extensibility** | Shell scripts | 33 extensions + 60+ skills + ClawHub |
|
|
53
54
|
| **Transport** | Binary frames (ultra-low latency) | JSON WebSocket |
|
|
54
55
|
| **Deployment** | Single-node Node.js | Single-node + Tailscale Serve/Funnel |
|
|
55
56
|
| **Tech Stack** | React + Express + node-pty | Lit + Express + Pi Agent |
|
|
56
|
-
| **Package Size** | ~
|
|
57
|
+
| **Package Size** | ~1 MB | ~300 MB+ |
|
|
57
58
|
| **Install** | `npx ai-cli-online` | `npm i -g openclaw && openclaw onboard` |
|
|
58
59
|
|
|
59
60
|
## Quick Start
|
|
@@ -121,8 +122,7 @@ Browser (xterm.js + WebGL) <-- WebSocket binary/JSON --> Express (node-pty) <-->
|
|
|
121
122
|
- **WebSocket compression** — `perMessageDeflate` (level 1, threshold 128 B), 50-70% bandwidth reduction
|
|
122
123
|
- **WebGL renderer** — 3-10x rendering throughput vs canvas
|
|
123
124
|
- **Parallel initialization** — PTY creation, tmux config, and resize run concurrently
|
|
124
|
-
- **
|
|
125
|
-
- **Smart re-render** — matchMedia threshold hook, conditional Zustand selectors, batched stat calls
|
|
125
|
+
- **Smart re-render** — responsive layout hook, conditional Zustand selectors, batched stat calls
|
|
126
126
|
|
|
127
127
|
## Project Structure
|
|
128
128
|
|
package/README.zh-CN.md
CHANGED
|
@@ -20,18 +20,19 @@
|
|
|
20
20
|
- **会话持久化** — tmux 保证断网后进程存活;固定 socket 路径,服务重启后自动重连
|
|
21
21
|
- **Tab 多标签页** — 独立终端分组,布局跨刷新持久化
|
|
22
22
|
- **分屏布局** — 水平/垂直任意嵌套分割
|
|
23
|
-
- **文档浏览器** — 终端旁查看 Markdown / HTML / PDF 文件
|
|
24
23
|
- **Mermaid 图表** — 甘特图、流程图等 Mermaid 图表暗色主题内联渲染,CDN 双源容错
|
|
25
|
-
- **Plan 批注** —
|
|
24
|
+
- **Plan 批注** — 4 种批注类型(插入/删除/替换/评注),选中文本弹出浮动按钮组,持久化存储
|
|
26
25
|
- **编辑器面板** — 多行编辑 + 草稿服务端持久化 (SQLite) + undo 撤销栈 + 斜杠命令(`/history` 等)
|
|
27
26
|
- **复制粘贴** — 鼠标选中自动复制到剪贴板,右键粘贴到终端
|
|
28
27
|
- **文件传输** — 上传文件到 CWD,浏览/下载通过 REST API
|
|
29
28
|
- **滚动历史** — capture-pane 回看,保留 ANSI 颜色
|
|
30
29
|
- **会话管理** — 侧边栏管理 session(恢复/删除/重命名)
|
|
30
|
+
- **中文等宽字体** — 通过 CDN 加载霞鹜文楷等宽 (LXGW WenKai Mono),unicode-range 按需分片,浏览器仅下载实际用到的字符
|
|
31
31
|
- **字体大小控制** — 可调节终端字体大小 (A−/A+),设置服务端持久化
|
|
32
32
|
- **网络指示器** — 实时 RTT 延迟显示 + 信号条
|
|
33
33
|
- **自动重连** — 指数退避 + jitter 防雷群效应
|
|
34
34
|
- **安全认证** — Token 认证 + timing-safe 比较
|
|
35
|
+
- **安全加固** — symlink 穿越防护、未认证 WebSocket 连接限制、TOCTOU 下载防护、CSP Headers (frame-ancestors / base-uri / form-action)
|
|
35
36
|
|
|
36
37
|
## 功能对比:AI-Cli Online vs OpenClaw
|
|
37
38
|
|
|
@@ -46,14 +47,14 @@
|
|
|
46
47
|
| **原生应用** | 无(纯 Web) | macOS + iOS + Android |
|
|
47
48
|
| **语音交互** | 无 | Voice Wake + Talk Mode |
|
|
48
49
|
| **AI Agent** | 无内置(运行任意 CLI) | Pi Agent 运行时 + 多 Agent 路由 |
|
|
49
|
-
| **Canvas/UI** |
|
|
50
|
+
| **Canvas/UI** | Plan 批注系统(Markdown) | A2UI 实时可视化工作区 |
|
|
50
51
|
| **文件传输** | REST API 上传/下载 | 渠道原生媒体 |
|
|
51
52
|
| **安全模型** | Token auth + timing-safe | 设备配对 + DM 策略 + Docker 沙箱 |
|
|
52
53
|
| **可扩展性** | Shell 脚本 | 33 扩展 + 60+ Skills + ClawHub |
|
|
53
54
|
| **传输协议** | 二进制帧(超低延迟) | JSON WebSocket |
|
|
54
55
|
| **部署** | 单机 Node.js | 单机 + Tailscale Serve/Funnel |
|
|
55
56
|
| **技术栈** | React + Express + node-pty | Lit + Express + Pi Agent |
|
|
56
|
-
| **包大小** | ~
|
|
57
|
+
| **包大小** | ~1 MB | ~300 MB+ |
|
|
57
58
|
| **安装** | `npx ai-cli-online` | `npm i -g openclaw && openclaw onboard` |
|
|
58
59
|
|
|
59
60
|
## 快速开始
|
|
@@ -121,8 +122,7 @@ TRUST_PROXY=1 # nginx 反代时设为 1
|
|
|
121
122
|
- **WebSocket 压缩** — `perMessageDeflate`(level 1,threshold 128 B),带宽减少 50-70%
|
|
122
123
|
- **WebGL 渲染器** — 渲染吞吐量提升 3-10 倍
|
|
123
124
|
- **并行初始化** — PTY 创建、tmux 配置、resize 并行执行
|
|
124
|
-
-
|
|
125
|
-
- **智能重渲染** — matchMedia 阈值 hook、条件 Zustand selector、分批 stat 调用
|
|
125
|
+
- **智能重渲染** — 响应式布局 hook、条件 Zustand selector、分批 stat 调用
|
|
126
126
|
|
|
127
127
|
## 项目结构
|
|
128
128
|
|
package/package.json
CHANGED
package/server/dist/db.js
CHANGED
|
@@ -35,6 +35,9 @@ db.exec(`
|
|
|
35
35
|
PRIMARY KEY (session_name, file_path)
|
|
36
36
|
)
|
|
37
37
|
`);
|
|
38
|
+
// Indexes for periodic cleanup queries on updated_at
|
|
39
|
+
db.exec('CREATE INDEX IF NOT EXISTS idx_drafts_updated_at ON drafts(updated_at)');
|
|
40
|
+
db.exec('CREATE INDEX IF NOT EXISTS idx_annotations_updated_at ON annotations(updated_at)');
|
|
38
41
|
// --- Annotations statements ---
|
|
39
42
|
const stmtAnnGet = db.prepare('SELECT content, updated_at FROM annotations WHERE session_name = ? AND file_path = ?');
|
|
40
43
|
const stmtAnnUpsert = db.prepare(`
|
package/server/dist/files.d.ts
CHANGED
|
@@ -17,5 +17,10 @@ export declare function listFiles(dirPath: string): Promise<ListFilesResult>;
|
|
|
17
17
|
* We resolve the path and ensure it's an absolute path that exists.
|
|
18
18
|
*/
|
|
19
19
|
export declare function validatePath(requested: string, baseCwd: string): Promise<string | null>;
|
|
20
|
+
/**
|
|
21
|
+
* Validate path and reject symlinks (A1).
|
|
22
|
+
* Used for download/file-content/stream-file to prevent symlink traversal.
|
|
23
|
+
*/
|
|
24
|
+
export declare function validatePathNoSymlink(requested: string, baseCwd: string): Promise<string | null>;
|
|
20
25
|
/** Validate a path that may not exist yet (for touch/mkdir). Uses realpath on baseCwd only. */
|
|
21
26
|
export declare function validateNewPath(requested: string, baseCwd: string): Promise<string | null>;
|
package/server/dist/files.js
CHANGED
|
@@ -1,7 +1,30 @@
|
|
|
1
|
-
import { readdir, stat, realpath } from 'fs/promises';
|
|
1
|
+
import { readdir, stat, lstat, realpath } from 'fs/promises';
|
|
2
2
|
import { join, resolve } from 'path';
|
|
3
3
|
export const MAX_UPLOAD_SIZE = 100 * 1024 * 1024; // 100 MB
|
|
4
4
|
export const MAX_DOWNLOAD_SIZE = 100 * 1024 * 1024; // 100 MB
|
|
5
|
+
/* ── realpath cache (C3) ── */
|
|
6
|
+
const realpathCache = new Map();
|
|
7
|
+
const REALPATH_CACHE_TTL = 5000; // 5s
|
|
8
|
+
const REALPATH_CACHE_MAX = 100;
|
|
9
|
+
async function cachedRealpath(p) {
|
|
10
|
+
const now = Date.now();
|
|
11
|
+
const cached = realpathCache.get(p);
|
|
12
|
+
if (cached && now < cached.expiresAt)
|
|
13
|
+
return cached.value;
|
|
14
|
+
const real = await realpath(p);
|
|
15
|
+
if (realpathCache.size >= REALPATH_CACHE_MAX) {
|
|
16
|
+
// Evict oldest entry
|
|
17
|
+
const first = realpathCache.keys().next().value;
|
|
18
|
+
if (first !== undefined)
|
|
19
|
+
realpathCache.delete(first);
|
|
20
|
+
}
|
|
21
|
+
realpathCache.set(p, { value: real, expiresAt: now + REALPATH_CACHE_TTL });
|
|
22
|
+
return real;
|
|
23
|
+
}
|
|
24
|
+
/* ── Path containment helper (B3) ── */
|
|
25
|
+
function isContainedIn(path, base) {
|
|
26
|
+
return path === base || path.startsWith(base + '/');
|
|
27
|
+
}
|
|
5
28
|
const MAX_DIR_ENTRIES = 1000;
|
|
6
29
|
/** List files in a directory, directories first, then alphabetical */
|
|
7
30
|
export async function listFiles(dirPath) {
|
|
@@ -48,25 +71,40 @@ export async function validatePath(requested, baseCwd) {
|
|
|
48
71
|
try {
|
|
49
72
|
const resolved = resolve(baseCwd, requested);
|
|
50
73
|
const real = await realpath(resolved);
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
if (real !== realBase && !real.startsWith(realBase + '/')) {
|
|
74
|
+
const realBase = await cachedRealpath(baseCwd);
|
|
75
|
+
if (!isContainedIn(real, realBase))
|
|
54
76
|
return null;
|
|
55
|
-
}
|
|
56
77
|
return real;
|
|
57
78
|
}
|
|
58
79
|
catch {
|
|
59
80
|
return null;
|
|
60
81
|
}
|
|
61
82
|
}
|
|
83
|
+
/**
|
|
84
|
+
* Validate path and reject symlinks (A1).
|
|
85
|
+
* Used for download/file-content/stream-file to prevent symlink traversal.
|
|
86
|
+
*/
|
|
87
|
+
export async function validatePathNoSymlink(requested, baseCwd) {
|
|
88
|
+
const resolved = await validatePath(requested, baseCwd);
|
|
89
|
+
if (!resolved)
|
|
90
|
+
return null;
|
|
91
|
+
try {
|
|
92
|
+
const s = await lstat(resolved);
|
|
93
|
+
if (s.isSymbolicLink())
|
|
94
|
+
return null;
|
|
95
|
+
return resolved;
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
62
101
|
/** Validate a path that may not exist yet (for touch/mkdir). Uses realpath on baseCwd only. */
|
|
63
102
|
export async function validateNewPath(requested, baseCwd) {
|
|
64
103
|
try {
|
|
65
|
-
const realBase = await
|
|
104
|
+
const realBase = await cachedRealpath(baseCwd);
|
|
66
105
|
const resolved = resolve(realBase, requested);
|
|
67
|
-
if (resolved
|
|
106
|
+
if (!isContainedIn(resolved, realBase))
|
|
68
107
|
return null;
|
|
69
|
-
}
|
|
70
108
|
return resolved;
|
|
71
109
|
}
|
|
72
110
|
catch {
|
package/server/dist/index.js
CHANGED
|
@@ -15,7 +15,7 @@ import { fileURLToPath } from 'url';
|
|
|
15
15
|
import { createHash } from 'crypto';
|
|
16
16
|
import { setupWebSocket, getActiveSessionNames, clearWsIntervals } from './websocket.js';
|
|
17
17
|
import { isTmuxAvailable, listSessions, buildSessionName, killSession, isValidSessionId, cleanupStaleSessions, getCwd, getPaneCommand } from './tmux.js';
|
|
18
|
-
import { listFiles, validatePath, validateNewPath, MAX_DOWNLOAD_SIZE, MAX_UPLOAD_SIZE } from './files.js';
|
|
18
|
+
import { listFiles, validatePath, validatePathNoSymlink, validateNewPath, MAX_DOWNLOAD_SIZE, MAX_UPLOAD_SIZE } from './files.js';
|
|
19
19
|
import { getDraft, saveDraft as saveDraftDb, deleteDraft, cleanupOldDrafts, getSetting, saveSetting, getAnnotation, saveAnnotation, cleanupOldAnnotations, closeDb } from './db.js';
|
|
20
20
|
import { safeTokenCompare } from './auth.js';
|
|
21
21
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
@@ -58,9 +58,13 @@ async function main() {
|
|
|
58
58
|
directives: {
|
|
59
59
|
defaultSrc: ["'self'"],
|
|
60
60
|
scriptSrc: ["'self'", "https://cdn.jsdelivr.net", "https://unpkg.com"],
|
|
61
|
-
styleSrc: ["'self'", "'unsafe-inline'"],
|
|
61
|
+
styleSrc: ["'self'", "'unsafe-inline'", "https://cdn.jsdelivr.net"],
|
|
62
|
+
fontSrc: ["'self'", "https://cdn.jsdelivr.net"],
|
|
62
63
|
imgSrc: ["'self'", "https:", "data:", "blob:"],
|
|
63
64
|
connectSrc: ["'self'", "wss:", "ws:"],
|
|
65
|
+
frameAncestors: ["'none'"],
|
|
66
|
+
baseUri: ["'self'"],
|
|
67
|
+
formAction: ["'self'"],
|
|
64
68
|
},
|
|
65
69
|
},
|
|
66
70
|
frameguard: { action: 'deny' },
|
|
@@ -255,7 +259,7 @@ async function main() {
|
|
|
255
259
|
res.status(400).json({ error: 'path query parameter required' });
|
|
256
260
|
return;
|
|
257
261
|
}
|
|
258
|
-
const resolved = await
|
|
262
|
+
const resolved = await validatePathNoSymlink(filePath, cwd);
|
|
259
263
|
if (!resolved) {
|
|
260
264
|
res.status(400).json({ error: 'Invalid path' });
|
|
261
265
|
return;
|
|
@@ -271,7 +275,25 @@ async function main() {
|
|
|
271
275
|
}
|
|
272
276
|
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(basename(resolved))}"`);
|
|
273
277
|
res.setHeader('Content-Length', fileStat.size);
|
|
274
|
-
|
|
278
|
+
// A3: Stream with byte counting to guard against TOCTOU size changes
|
|
279
|
+
const stream = createReadStream(resolved);
|
|
280
|
+
let bytesWritten = 0;
|
|
281
|
+
stream.on('data', (chunk) => {
|
|
282
|
+
const buf = typeof chunk === 'string' ? Buffer.from(chunk) : chunk;
|
|
283
|
+
bytesWritten += buf.length;
|
|
284
|
+
if (bytesWritten > MAX_DOWNLOAD_SIZE) {
|
|
285
|
+
stream.destroy();
|
|
286
|
+
if (!res.writableEnded)
|
|
287
|
+
res.end();
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
if (!res.writableEnded)
|
|
291
|
+
res.write(chunk);
|
|
292
|
+
});
|
|
293
|
+
stream.on('end', () => { if (!res.writableEnded)
|
|
294
|
+
res.end(); });
|
|
295
|
+
stream.on('error', () => { if (!res.writableEnded)
|
|
296
|
+
res.end(); });
|
|
275
297
|
}
|
|
276
298
|
catch (err) {
|
|
277
299
|
console.error(`[api:download] ${sessionName}:`, err);
|
|
@@ -285,6 +307,11 @@ async function main() {
|
|
|
285
307
|
return;
|
|
286
308
|
try {
|
|
287
309
|
const cwd = await getCwd(sessionName);
|
|
310
|
+
// A5: Validate CWD format before passing to tar
|
|
311
|
+
if (!cwd.startsWith('/') || cwd.includes('\0')) {
|
|
312
|
+
res.status(400).json({ error: 'Invalid working directory' });
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
288
315
|
const dirName = basename(cwd);
|
|
289
316
|
res.setHeader('Content-Type', 'application/gzip');
|
|
290
317
|
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(dirName)}.tar.gz"`);
|
|
@@ -547,7 +574,7 @@ async function main() {
|
|
|
547
574
|
}
|
|
548
575
|
try {
|
|
549
576
|
const cwd = await getCwd(sessionName);
|
|
550
|
-
const resolved = await
|
|
577
|
+
const resolved = await validatePathNoSymlink(filePath, cwd);
|
|
551
578
|
if (!resolved) {
|
|
552
579
|
res.status(400).json({ error: 'Invalid path' });
|
|
553
580
|
return;
|
package/server/dist/websocket.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { WebSocket } from 'ws';
|
|
2
2
|
import { buildSessionName, isValidSessionId, tokenToSessionName, hasSession, createSession, configureSession, captureScrollback, resizeSession, getCwd, } from './tmux.js';
|
|
3
|
-
import {
|
|
3
|
+
import { validatePathNoSymlink } from './files.js';
|
|
4
4
|
import { createReadStream } from 'fs';
|
|
5
5
|
import { stat as fsStat } from 'fs/promises';
|
|
6
6
|
import { PtySession } from './pty.js';
|
|
7
7
|
import { BIN_TYPE_OUTPUT, BIN_TYPE_INPUT, BIN_TYPE_SCROLLBACK, BIN_TYPE_SCROLLBACK_CONTENT, BIN_TYPE_FILE_CHUNK } from 'ai-cli-online-shared';
|
|
8
|
+
// A2: Limit pending (unauthenticated) WebSocket connections
|
|
9
|
+
const MAX_PENDING_AUTH = 50;
|
|
10
|
+
let pendingAuthCount = 0;
|
|
8
11
|
const MAX_STREAM_SIZE = 50 * 1024 * 1024; // 50MB
|
|
9
12
|
const STREAM_CHUNK_SIZE = 64 * 1024; // 64KB highWaterMark
|
|
10
13
|
const STREAM_HIGH_WATER = 1024 * 1024; // 1MB backpressure threshold
|
|
@@ -111,9 +114,24 @@ export function setupWebSocket(wss, authToken, defaultCwd, tokenCompare, maxConn
|
|
|
111
114
|
socket.setNoDelay(true);
|
|
112
115
|
}
|
|
113
116
|
const clientIp = req.socket.remoteAddress || 'unknown';
|
|
117
|
+
// A2: Reject when too many unauthenticated connections are pending
|
|
118
|
+
let countedAsPending = false;
|
|
119
|
+
if (authToken) {
|
|
120
|
+
if (pendingAuthCount >= MAX_PENDING_AUTH) {
|
|
121
|
+
console.log(`[WS] Pending auth limit (${MAX_PENDING_AUTH}) reached, rejecting connection from ${clientIp}`);
|
|
122
|
+
ws.close(4006, 'Too many pending connections');
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
pendingAuthCount++;
|
|
126
|
+
countedAsPending = true;
|
|
127
|
+
}
|
|
114
128
|
// Reject connections from IPs with too many recent auth failures
|
|
115
129
|
if (authToken && isAuthRateLimited(clientIp)) {
|
|
116
130
|
console.log(`[WS] Auth rate-limited IP: ${clientIp}`);
|
|
131
|
+
if (countedAsPending) {
|
|
132
|
+
pendingAuthCount--;
|
|
133
|
+
countedAsPending = false;
|
|
134
|
+
}
|
|
117
135
|
ws.close(4001, 'Too many auth failures');
|
|
118
136
|
return;
|
|
119
137
|
}
|
|
@@ -142,6 +160,10 @@ export function setupWebSocket(wss, authToken, defaultCwd, tokenCompare, maxConn
|
|
|
142
160
|
const authTimer = authToken
|
|
143
161
|
? setTimeout(() => {
|
|
144
162
|
if (!authenticated) {
|
|
163
|
+
if (countedAsPending) {
|
|
164
|
+
pendingAuthCount--;
|
|
165
|
+
countedAsPending = false;
|
|
166
|
+
}
|
|
145
167
|
console.log('[WS] Auth timeout — no auth message received');
|
|
146
168
|
ws.close(4001, 'Auth timeout');
|
|
147
169
|
}
|
|
@@ -257,9 +279,17 @@ export function setupWebSocket(wss, authToken, defaultCwd, tokenCompare, maxConn
|
|
|
257
279
|
if (!msg.token || !compareToken(msg.token, authToken)) {
|
|
258
280
|
recordAuthFailure(clientIp);
|
|
259
281
|
console.log(`[WS] Unauthorized — invalid token from ${clientIp}`);
|
|
282
|
+
if (countedAsPending) {
|
|
283
|
+
pendingAuthCount--;
|
|
284
|
+
countedAsPending = false;
|
|
285
|
+
}
|
|
260
286
|
ws.close(4001, 'Unauthorized');
|
|
261
287
|
return;
|
|
262
288
|
}
|
|
289
|
+
if (countedAsPending) {
|
|
290
|
+
pendingAuthCount--;
|
|
291
|
+
countedAsPending = false;
|
|
292
|
+
}
|
|
263
293
|
authenticated = true;
|
|
264
294
|
authenticatedToken = msg.token;
|
|
265
295
|
await initSession(msg.token);
|
|
@@ -306,7 +336,7 @@ export function setupWebSocket(wss, authToken, defaultCwd, tokenCompare, maxConn
|
|
|
306
336
|
}
|
|
307
337
|
try {
|
|
308
338
|
const cwd = await getCwd(sessionName);
|
|
309
|
-
const resolved = await
|
|
339
|
+
const resolved = await validatePathNoSymlink(msg.path, cwd);
|
|
310
340
|
if (!resolved) {
|
|
311
341
|
send(ws, { type: 'file-stream-error', error: 'Invalid path' });
|
|
312
342
|
break;
|
|
@@ -333,18 +363,18 @@ export function setupWebSocket(wss, authToken, defaultCwd, tokenCompare, maxConn
|
|
|
333
363
|
buf[0] = BIN_TYPE_FILE_CHUNK;
|
|
334
364
|
buf.set(data, 1);
|
|
335
365
|
ws.send(buf);
|
|
336
|
-
// Backpressure: pause stream when WS send buffer is full
|
|
366
|
+
// Backpressure: pause stream when WS send buffer is full, resume on drain
|
|
337
367
|
if (ws.bufferedAmount > STREAM_HIGH_WATER) {
|
|
338
368
|
stream.pause();
|
|
339
|
-
const
|
|
369
|
+
const onDrain = () => {
|
|
340
370
|
if (ws.bufferedAmount < STREAM_LOW_WATER) {
|
|
341
371
|
stream.resume();
|
|
342
372
|
}
|
|
343
373
|
else {
|
|
344
|
-
|
|
374
|
+
ws.once('drain', onDrain);
|
|
345
375
|
}
|
|
346
376
|
};
|
|
347
|
-
|
|
377
|
+
ws.once('drain', onDrain);
|
|
348
378
|
}
|
|
349
379
|
});
|
|
350
380
|
stream.on('end', () => {
|
|
@@ -375,6 +405,10 @@ export function setupWebSocket(wss, authToken, defaultCwd, tokenCompare, maxConn
|
|
|
375
405
|
}
|
|
376
406
|
});
|
|
377
407
|
ws.on('close', () => {
|
|
408
|
+
if (countedAsPending) {
|
|
409
|
+
pendingAuthCount--;
|
|
410
|
+
countedAsPending = false;
|
|
411
|
+
}
|
|
378
412
|
if (authTimer)
|
|
379
413
|
clearTimeout(authTimer);
|
|
380
414
|
if (activeFileStream) {
|
package/server/package.json
CHANGED