ai-cli-online 2.5.1 → 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.d.ts +6 -0
- package/server/dist/db.js +38 -0
- package/server/dist/files.d.ts +7 -0
- package/server/dist/files.js +57 -5
- package/server/dist/index.js +181 -13
- package/server/dist/websocket.js +43 -17
- package/server/package.json +1 -1
- package/shared/dist/types.d.ts +9 -0
- package/shared/dist/types.js +9 -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-ClJzpPT-.css +0 -32
- package/web/dist/assets/index-zFa9lx6P.js +0 -33
- package/web/dist/assets/markdown-BERZKN_L.js +0 -60
- package/web/dist/assets/pdf-Tk4_4Bu3.js +0 -12
- package/web/dist/assets/pdf.worker-BA9kU3Pw.mjs +0 -61080
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.d.ts
CHANGED
|
@@ -4,4 +4,10 @@ export declare function getDraft(sessionName: string): string;
|
|
|
4
4
|
export declare function saveDraft(sessionName: string, content: string): void;
|
|
5
5
|
export declare function deleteDraft(sessionName: string): void;
|
|
6
6
|
export declare function cleanupOldDrafts(maxAgeDays?: number): number;
|
|
7
|
+
export declare function getAnnotation(sessionName: string, filePath: string): {
|
|
8
|
+
content: string;
|
|
9
|
+
updatedAt: number;
|
|
10
|
+
} | null;
|
|
11
|
+
export declare function saveAnnotation(sessionName: string, filePath: string, content: string, updatedAt: number): void;
|
|
12
|
+
export declare function cleanupOldAnnotations(maxAgeDays?: number): number;
|
|
7
13
|
export declare function closeDb(): void;
|
package/server/dist/db.js
CHANGED
|
@@ -26,6 +26,26 @@ db.exec(`
|
|
|
26
26
|
PRIMARY KEY (token_hash, key)
|
|
27
27
|
)
|
|
28
28
|
`);
|
|
29
|
+
db.exec(`
|
|
30
|
+
CREATE TABLE IF NOT EXISTS annotations (
|
|
31
|
+
session_name TEXT NOT NULL,
|
|
32
|
+
file_path TEXT NOT NULL,
|
|
33
|
+
content TEXT NOT NULL DEFAULT '{}',
|
|
34
|
+
updated_at INTEGER NOT NULL,
|
|
35
|
+
PRIMARY KEY (session_name, file_path)
|
|
36
|
+
)
|
|
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)');
|
|
41
|
+
// --- Annotations statements ---
|
|
42
|
+
const stmtAnnGet = db.prepare('SELECT content, updated_at FROM annotations WHERE session_name = ? AND file_path = ?');
|
|
43
|
+
const stmtAnnUpsert = db.prepare(`
|
|
44
|
+
INSERT INTO annotations (session_name, file_path, content, updated_at) VALUES (?, ?, ?, ?)
|
|
45
|
+
ON CONFLICT(session_name, file_path) DO UPDATE SET content = excluded.content, updated_at = excluded.updated_at
|
|
46
|
+
`);
|
|
47
|
+
const stmtAnnDelete = db.prepare('DELETE FROM annotations WHERE session_name = ? AND file_path = ?');
|
|
48
|
+
const stmtAnnCleanup = db.prepare('DELETE FROM annotations WHERE updated_at < ?');
|
|
29
49
|
// --- Drafts statements ---
|
|
30
50
|
const stmtGet = db.prepare('SELECT content FROM drafts WHERE session_name = ?');
|
|
31
51
|
const stmtUpsert = db.prepare(`
|
|
@@ -68,6 +88,24 @@ export function cleanupOldDrafts(maxAgeDays = 7) {
|
|
|
68
88
|
const result = stmtCleanup.run(cutoff);
|
|
69
89
|
return result.changes;
|
|
70
90
|
}
|
|
91
|
+
// --- Annotation functions ---
|
|
92
|
+
export function getAnnotation(sessionName, filePath) {
|
|
93
|
+
const row = stmtAnnGet.get(sessionName, filePath);
|
|
94
|
+
return row ? { content: row.content, updatedAt: row.updated_at } : null;
|
|
95
|
+
}
|
|
96
|
+
export function saveAnnotation(sessionName, filePath, content, updatedAt) {
|
|
97
|
+
if (!content || content === '{}' || content === '{"additions":[],"deletions":[]}') {
|
|
98
|
+
stmtAnnDelete.run(sessionName, filePath);
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
stmtAnnUpsert.run(sessionName, filePath, content, updatedAt);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
export function cleanupOldAnnotations(maxAgeDays = 7) {
|
|
105
|
+
const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000;
|
|
106
|
+
const result = stmtAnnCleanup.run(cutoff);
|
|
107
|
+
return result.changes;
|
|
108
|
+
}
|
|
71
109
|
export function closeDb() {
|
|
72
110
|
db.close();
|
|
73
111
|
}
|
package/server/dist/files.d.ts
CHANGED
|
@@ -17,3 +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>;
|
|
25
|
+
/** Validate a path that may not exist yet (for touch/mkdir). Uses realpath on baseCwd only. */
|
|
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,14 +71,43 @@ 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
|
+
}
|
|
101
|
+
/** Validate a path that may not exist yet (for touch/mkdir). Uses realpath on baseCwd only. */
|
|
102
|
+
export async function validateNewPath(requested, baseCwd) {
|
|
103
|
+
try {
|
|
104
|
+
const realBase = await cachedRealpath(baseCwd);
|
|
105
|
+
const resolved = resolve(realBase, requested);
|
|
106
|
+
if (!isContainedIn(resolved, realBase))
|
|
107
|
+
return null;
|
|
108
|
+
return resolved;
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
package/server/dist/index.js
CHANGED
|
@@ -8,14 +8,15 @@ import rateLimit from 'express-rate-limit';
|
|
|
8
8
|
import multer from 'multer';
|
|
9
9
|
import { config } from 'dotenv';
|
|
10
10
|
import { existsSync, readFileSync, createReadStream } from 'fs';
|
|
11
|
-
import {
|
|
11
|
+
import { spawn } from 'child_process';
|
|
12
|
+
import { copyFile, unlink, stat, mkdir, readFile, writeFile, rm } from 'fs/promises';
|
|
12
13
|
import { join, dirname, basename, extname } from 'path';
|
|
13
14
|
import { fileURLToPath } from 'url';
|
|
14
15
|
import { createHash } from 'crypto';
|
|
15
16
|
import { setupWebSocket, getActiveSessionNames, clearWsIntervals } from './websocket.js';
|
|
16
17
|
import { isTmuxAvailable, listSessions, buildSessionName, killSession, isValidSessionId, cleanupStaleSessions, getCwd, getPaneCommand } from './tmux.js';
|
|
17
|
-
import { listFiles, validatePath, MAX_DOWNLOAD_SIZE, MAX_UPLOAD_SIZE } from './files.js';
|
|
18
|
-
import { getDraft, saveDraft as saveDraftDb, deleteDraft, cleanupOldDrafts, getSetting, saveSetting, closeDb } from './db.js';
|
|
18
|
+
import { listFiles, validatePath, validatePathNoSymlink, validateNewPath, MAX_DOWNLOAD_SIZE, MAX_UPLOAD_SIZE } from './files.js';
|
|
19
|
+
import { getDraft, saveDraft as saveDraftDb, deleteDraft, cleanupOldDrafts, getSetting, saveSetting, getAnnotation, saveAnnotation, cleanupOldAnnotations, closeDb } from './db.js';
|
|
19
20
|
import { safeTokenCompare } from './auth.js';
|
|
20
21
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
21
22
|
config();
|
|
@@ -57,9 +58,13 @@ async function main() {
|
|
|
57
58
|
directives: {
|
|
58
59
|
defaultSrc: ["'self'"],
|
|
59
60
|
scriptSrc: ["'self'", "https://cdn.jsdelivr.net", "https://unpkg.com"],
|
|
60
|
-
styleSrc: ["'self'", "'unsafe-inline'"],
|
|
61
|
+
styleSrc: ["'self'", "'unsafe-inline'", "https://cdn.jsdelivr.net"],
|
|
62
|
+
fontSrc: ["'self'", "https://cdn.jsdelivr.net"],
|
|
61
63
|
imgSrc: ["'self'", "https:", "data:", "blob:"],
|
|
62
64
|
connectSrc: ["'self'", "wss:", "ws:"],
|
|
65
|
+
frameAncestors: ["'none'"],
|
|
66
|
+
baseUri: ["'self'"],
|
|
67
|
+
formAction: ["'self'"],
|
|
63
68
|
},
|
|
64
69
|
},
|
|
65
70
|
frameguard: { action: 'deny' },
|
|
@@ -178,13 +183,24 @@ async function main() {
|
|
|
178
183
|
try {
|
|
179
184
|
const cwd = await getCwd(sessionName);
|
|
180
185
|
const subPath = req.query.path || '';
|
|
181
|
-
|
|
186
|
+
let targetDir = null;
|
|
187
|
+
if (subPath) {
|
|
188
|
+
targetDir = await validatePath(subPath, cwd);
|
|
189
|
+
// Fallback: allow absolute paths under HOME (e.g., ~/.claude/commands)
|
|
190
|
+
if (!targetDir) {
|
|
191
|
+
const home = process.env.HOME || '/root';
|
|
192
|
+
targetDir = await validatePath(subPath, home);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
targetDir = cwd;
|
|
197
|
+
}
|
|
182
198
|
if (!targetDir) {
|
|
183
199
|
res.status(400).json({ error: 'Invalid path' });
|
|
184
200
|
return;
|
|
185
201
|
}
|
|
186
202
|
const { files, truncated } = await listFiles(targetDir);
|
|
187
|
-
res.json({ cwd: targetDir, files, truncated });
|
|
203
|
+
res.json({ cwd: targetDir, home: process.env.HOME || '/root', files, truncated });
|
|
188
204
|
}
|
|
189
205
|
catch (err) {
|
|
190
206
|
console.error(`[api:files] ${sessionName}:`, err);
|
|
@@ -243,7 +259,7 @@ async function main() {
|
|
|
243
259
|
res.status(400).json({ error: 'path query parameter required' });
|
|
244
260
|
return;
|
|
245
261
|
}
|
|
246
|
-
const resolved = await
|
|
262
|
+
const resolved = await validatePathNoSymlink(filePath, cwd);
|
|
247
263
|
if (!resolved) {
|
|
248
264
|
res.status(400).json({ error: 'Invalid path' });
|
|
249
265
|
return;
|
|
@@ -259,13 +275,66 @@ async function main() {
|
|
|
259
275
|
}
|
|
260
276
|
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(basename(resolved))}"`);
|
|
261
277
|
res.setHeader('Content-Length', fileStat.size);
|
|
262
|
-
|
|
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(); });
|
|
263
297
|
}
|
|
264
298
|
catch (err) {
|
|
265
299
|
console.error(`[api:download] ${sessionName}:`, err);
|
|
266
300
|
res.status(404).json({ error: 'File not found' });
|
|
267
301
|
}
|
|
268
302
|
});
|
|
303
|
+
// Download CWD as tar.gz
|
|
304
|
+
app.get('/api/sessions/:sessionId/download-cwd', async (req, res) => {
|
|
305
|
+
const sessionName = resolveSession(req, res);
|
|
306
|
+
if (!sessionName)
|
|
307
|
+
return;
|
|
308
|
+
try {
|
|
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
|
+
}
|
|
315
|
+
const dirName = basename(cwd);
|
|
316
|
+
res.setHeader('Content-Type', 'application/gzip');
|
|
317
|
+
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(dirName)}.tar.gz"`);
|
|
318
|
+
const tar = spawn('tar', ['czf', '-', '-C', cwd, '.'], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
319
|
+
tar.stdout.pipe(res);
|
|
320
|
+
tar.stderr.on('data', (data) => console.error(`[tar stderr] ${data}`));
|
|
321
|
+
tar.on('error', (err) => {
|
|
322
|
+
console.error('[api:download-cwd] tar error:', err);
|
|
323
|
+
if (!res.headersSent)
|
|
324
|
+
res.status(500).json({ error: 'Failed to create archive' });
|
|
325
|
+
});
|
|
326
|
+
tar.on('close', (code) => {
|
|
327
|
+
if (code !== 0 && !res.headersSent) {
|
|
328
|
+
res.status(500).json({ error: 'Archive creation failed' });
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
catch (err) {
|
|
333
|
+
console.error(`[api:download-cwd] ${sessionName}:`, err);
|
|
334
|
+
if (!res.headersSent)
|
|
335
|
+
res.status(500).json({ error: 'Failed to download' });
|
|
336
|
+
}
|
|
337
|
+
});
|
|
269
338
|
// --- Draft API ---
|
|
270
339
|
// Get draft for a session
|
|
271
340
|
app.get('/api/sessions/:sessionId/draft', (req, res) => {
|
|
@@ -288,6 +357,37 @@ async function main() {
|
|
|
288
357
|
saveDraftDb(sessionName, content);
|
|
289
358
|
res.json({ ok: true });
|
|
290
359
|
});
|
|
360
|
+
// --- Annotations API ---
|
|
361
|
+
// Get annotation for a file
|
|
362
|
+
app.get('/api/sessions/:sessionId/annotations', (req, res) => {
|
|
363
|
+
const sessionName = resolveSession(req, res);
|
|
364
|
+
if (!sessionName)
|
|
365
|
+
return;
|
|
366
|
+
const filePath = req.query.path;
|
|
367
|
+
if (!filePath) {
|
|
368
|
+
res.status(400).json({ error: 'path query parameter required' });
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
const result = getAnnotation(sessionName, filePath);
|
|
372
|
+
res.json(result || { content: null, updatedAt: 0 });
|
|
373
|
+
});
|
|
374
|
+
// Save (upsert) annotation for a file
|
|
375
|
+
app.put('/api/sessions/:sessionId/annotations', (req, res) => {
|
|
376
|
+
const sessionName = resolveSession(req, res);
|
|
377
|
+
if (!sessionName)
|
|
378
|
+
return;
|
|
379
|
+
const { path: filePath, content, updatedAt } = req.body;
|
|
380
|
+
if (!filePath || typeof filePath !== 'string') {
|
|
381
|
+
res.status(400).json({ error: 'path must be a string' });
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
if (typeof content !== 'string') {
|
|
385
|
+
res.status(400).json({ error: 'content must be a string' });
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
saveAnnotation(sessionName, filePath, content, updatedAt || Date.now());
|
|
389
|
+
res.json({ ok: true });
|
|
390
|
+
});
|
|
291
391
|
// --- Pane command API ---
|
|
292
392
|
// Get current pane command (to detect if claude is running)
|
|
293
393
|
app.get('/api/sessions/:sessionId/pane-command', async (req, res) => {
|
|
@@ -309,14 +409,20 @@ async function main() {
|
|
|
309
409
|
return;
|
|
310
410
|
try {
|
|
311
411
|
const { name } = req.body;
|
|
312
|
-
if (!name || typeof name !== 'string' || name.includes('
|
|
412
|
+
if (!name || typeof name !== 'string' || name.includes('..')) {
|
|
313
413
|
res.status(400).json({ error: 'Invalid filename' });
|
|
314
414
|
return;
|
|
315
415
|
}
|
|
316
416
|
const cwd = await getCwd(sessionName);
|
|
317
|
-
const
|
|
318
|
-
|
|
319
|
-
|
|
417
|
+
const resolved = await validateNewPath(join(cwd, name), cwd);
|
|
418
|
+
if (!resolved) {
|
|
419
|
+
res.status(400).json({ error: 'Invalid path' });
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
// Ensure parent directory exists (supports paths like "PLAN/INDEX.md")
|
|
423
|
+
await mkdir(dirname(resolved), { recursive: true });
|
|
424
|
+
await writeFile(resolved, '', { flag: 'wx' }); // create exclusively
|
|
425
|
+
res.json({ ok: true, path: resolved });
|
|
320
426
|
}
|
|
321
427
|
catch (err) {
|
|
322
428
|
if (err && typeof err === 'object' && 'code' in err && err.code === 'EEXIST') {
|
|
@@ -329,6 +435,62 @@ async function main() {
|
|
|
329
435
|
}
|
|
330
436
|
}
|
|
331
437
|
});
|
|
438
|
+
// --- Mkdir (create directory) API ---
|
|
439
|
+
app.post('/api/sessions/:sessionId/mkdir', async (req, res) => {
|
|
440
|
+
const sessionName = resolveSession(req, res);
|
|
441
|
+
if (!sessionName)
|
|
442
|
+
return;
|
|
443
|
+
try {
|
|
444
|
+
const { path: dirPath } = req.body;
|
|
445
|
+
if (!dirPath || typeof dirPath !== 'string' || dirPath.includes('..')) {
|
|
446
|
+
res.status(400).json({ error: 'Invalid path' });
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
const cwd = await getCwd(sessionName);
|
|
450
|
+
const resolved = await validateNewPath(join(cwd, dirPath), cwd);
|
|
451
|
+
if (!resolved) {
|
|
452
|
+
res.status(400).json({ error: 'Invalid path' });
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
await mkdir(resolved, { recursive: true });
|
|
456
|
+
res.json({ ok: true, path: resolved });
|
|
457
|
+
}
|
|
458
|
+
catch (err) {
|
|
459
|
+
console.error(`[api:mkdir] ${sessionName}:`, err);
|
|
460
|
+
res.status(500).json({ error: 'Failed to create directory' });
|
|
461
|
+
}
|
|
462
|
+
});
|
|
463
|
+
// --- Delete file/directory API ---
|
|
464
|
+
app.delete('/api/sessions/:sessionId/rm', async (req, res) => {
|
|
465
|
+
const sessionName = resolveSession(req, res);
|
|
466
|
+
if (!sessionName)
|
|
467
|
+
return;
|
|
468
|
+
try {
|
|
469
|
+
const { path: rmPath } = req.body;
|
|
470
|
+
if (!rmPath || typeof rmPath !== 'string' || rmPath.includes('..')) {
|
|
471
|
+
res.status(400).json({ error: 'Invalid path' });
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
const cwd = await getCwd(sessionName);
|
|
475
|
+
const resolved = await validatePath(rmPath, cwd);
|
|
476
|
+
if (!resolved) {
|
|
477
|
+
res.status(400).json({ error: 'Invalid path' });
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
const fileStat = await stat(resolved);
|
|
481
|
+
if (fileStat.isDirectory()) {
|
|
482
|
+
await rm(resolved, { recursive: true });
|
|
483
|
+
}
|
|
484
|
+
else {
|
|
485
|
+
await unlink(resolved);
|
|
486
|
+
}
|
|
487
|
+
res.json({ ok: true });
|
|
488
|
+
}
|
|
489
|
+
catch (err) {
|
|
490
|
+
console.error(`[api:rm] ${sessionName}:`, err);
|
|
491
|
+
res.status(500).json({ error: 'Failed to delete' });
|
|
492
|
+
}
|
|
493
|
+
});
|
|
332
494
|
// --- Settings API ---
|
|
333
495
|
/** Hash token for settings storage (same prefix as tmux session names) */
|
|
334
496
|
function tokenHash(token) {
|
|
@@ -412,7 +574,7 @@ async function main() {
|
|
|
412
574
|
}
|
|
413
575
|
try {
|
|
414
576
|
const cwd = await getCwd(sessionName);
|
|
415
|
-
const resolved = await
|
|
577
|
+
const resolved = await validatePathNoSymlink(filePath, cwd);
|
|
416
578
|
if (!resolved) {
|
|
417
579
|
res.status(400).json({ error: 'Invalid path' });
|
|
418
580
|
return;
|
|
@@ -530,6 +692,12 @@ async function main() {
|
|
|
530
692
|
catch (e) {
|
|
531
693
|
console.error('[cleanup:drafts]', e);
|
|
532
694
|
}
|
|
695
|
+
try {
|
|
696
|
+
cleanupOldAnnotations(7);
|
|
697
|
+
}
|
|
698
|
+
catch (e) {
|
|
699
|
+
console.error('[cleanup:annotations]', e);
|
|
700
|
+
}
|
|
533
701
|
}, CLEANUP_INTERVAL);
|
|
534
702
|
console.log(`Session TTL: ${SESSION_TTL_HOURS}h (cleanup every hour)`);
|
|
535
703
|
}
|