@xcanwin/manyoyo 8.6.2 → 8.6.4
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/lib/app-update.js +29 -27
- package/lib/core/event-store.js +23 -1
- package/lib/init-config.js +16 -2
- package/lib/web/AGENTS.md +14 -3
- package/lib/web/container-manage.js +4 -2
- package/lib/web/history-store.js +221 -0
- package/lib/web/index.html +89 -89
- package/lib/web/messages-view.js +182 -0
- package/lib/web/server.js +556 -80
- package/package.json +1 -1
package/lib/app-update.js
CHANGED
|
@@ -11,7 +11,7 @@ const { spawnSync } = require('child_process');
|
|
|
11
11
|
const { downloadVerified, sha256File } = require('./download-verified');
|
|
12
12
|
|
|
13
13
|
const REPO = 'xcanwin/manyoyo';
|
|
14
|
-
const
|
|
14
|
+
const WEB_BASE = 'https://github.com';
|
|
15
15
|
// 不依赖 PATH 查找 tar(macOS 与常见 Linux 都在 /usr/bin)
|
|
16
16
|
const TAR_COMMAND = fs.existsSync('/usr/bin/tar') ? '/usr/bin/tar' : 'tar';
|
|
17
17
|
const VERSION_PATTERN = /^\d+\.\d+\.\d+$/;
|
|
@@ -103,35 +103,32 @@ async function request(fetchImpl, url, options = {}) {
|
|
|
103
103
|
}
|
|
104
104
|
|
|
105
105
|
/**
|
|
106
|
-
* 查询最新 Release
|
|
106
|
+
* 查询最新 Release。返回 { version, tag, assets: { name: url } }。
|
|
107
|
+
* 不调 GitHub API:匿名 API 按 IP 每小时 60 次,共享出口(公司网络、代理、CI runner)经常被限流。
|
|
108
|
+
* 改为跟随 github.com 的 releases/latest 跳转取版本(与 scripts/install.sh 一致),
|
|
109
|
+
* 升级包与 SHA256SUMS 的命名是固定的,下载地址直接拼在本仓库的 Release 下载路径下。
|
|
107
110
|
*/
|
|
108
|
-
async function fetchLatestRelease({ fetchImpl = fetch,
|
|
109
|
-
const response = await request(fetchImpl, `${
|
|
110
|
-
if (response.status ===
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
if (
|
|
119
|
-
|
|
120
|
-
let release;
|
|
121
|
-
try {
|
|
122
|
-
release = await response.json();
|
|
123
|
-
} catch (error) {
|
|
124
|
-
throw new UpdateError('HTTP', '查询最新版本失败(返回内容不是有效的 JSON)。');
|
|
125
|
-
}
|
|
126
|
-
const version = String(release.tag_name || '').replace(/^v/, '');
|
|
127
|
-
if (!VERSION_PATTERN.test(version)) throw new UpdateError('HTTP', `最新 Release 的版本号无法识别: ${release.tag_name}`);
|
|
128
|
-
// 默认 API 下,下载地址必须在本仓库的 Release 下载路径内(测试注入自定义 apiBase 时不限制)
|
|
129
|
-
const allowedPrefix = apiBase === API_BASE ? `https://github.com/${REPO}/releases/download/` : '';
|
|
111
|
+
async function fetchLatestRelease({ fetchImpl = fetch, webBase = WEB_BASE } = {}) {
|
|
112
|
+
const response = await request(fetchImpl, `${webBase}/${REPO}/releases/latest`, { redirect: 'manual', headers: { Accept: 'text/html' } });
|
|
113
|
+
if (response.status === 429) throw new UpdateError('RATE_LIMIT', 'GitHub 对访问做了频率限制,请稍后重试。');
|
|
114
|
+
const location = response.status >= 300 && response.status < 400 ? String(response.headers.get('location') || '') : '';
|
|
115
|
+
if (!location && !response.ok && response.status !== 404) throw new UpdateError('HTTP', `查询最新版本失败(HTTP ${response.status})。`);
|
|
116
|
+
// 没有任何 Release 时 latest 跳到 Release 列表页(或 404)
|
|
117
|
+
const match = /\/releases\/tag\/([^/?#]+)$/.exec(location);
|
|
118
|
+
if (!match) throw new UpdateError('NO_RELEASE', '仓库还没有发布的 Release,暂时无法升级。');
|
|
119
|
+
const tag = decodeURIComponent(match[1]);
|
|
120
|
+
const version = tag.replace(/^v/, '');
|
|
121
|
+
if (!VERSION_PATTERN.test(version)) throw new UpdateError('HTTP', `最新 Release 的版本号无法识别: ${tag}`);
|
|
122
|
+
const download = `${webBase}/${REPO}/releases/download/${encodeURIComponent(tag)}/`;
|
|
130
123
|
const assets = {};
|
|
131
|
-
for (const
|
|
132
|
-
|
|
124
|
+
for (const targetOs of ['macos', 'linux']) {
|
|
125
|
+
for (const targetArch of ['arm64', 'x64']) {
|
|
126
|
+
const names = releaseAssetNames(version, targetArch, targetOs);
|
|
127
|
+
assets[names.app] = `${download}${names.app}`;
|
|
128
|
+
assets[names.sums] = `${download}${names.sums}`;
|
|
129
|
+
}
|
|
133
130
|
}
|
|
134
|
-
return { version, tag
|
|
131
|
+
return { version, tag, assets };
|
|
135
132
|
}
|
|
136
133
|
|
|
137
134
|
function parseSha256Sums(text) {
|
|
@@ -250,6 +247,11 @@ async function installAppUpdate({ appRoot, release, fetchImpl = fetch, targetArc
|
|
|
250
247
|
throw new UpdateError('NO_ASSET', `Release ${release.version} 里没有适合这台机器(${targetOs}-${targetArch})的升级包(需要 ${names.app} 与 ${names.sums})。`);
|
|
251
248
|
}
|
|
252
249
|
|
|
250
|
+
// 下载地址是按固定命名拼出来的:先确认这个 Release 里确实有本机的升级包(404 就是没有)
|
|
251
|
+
const probe = await request(fetchImpl, appUrl, { method: 'HEAD' });
|
|
252
|
+
if (probe.status === 404) {
|
|
253
|
+
throw new UpdateError('NO_ASSET', `Release ${release.version} 里没有适合这台机器(${targetOs}-${targetArch})的升级包(需要 ${names.app} 与 ${names.sums})。`);
|
|
254
|
+
}
|
|
253
255
|
log(`校验清单: ${sumsUrl}`);
|
|
254
256
|
const sums = parseSha256Sums(await fetchText(fetchImpl, sumsUrl));
|
|
255
257
|
const expected = sums[names.app];
|
package/lib/core/event-store.js
CHANGED
|
@@ -67,12 +67,34 @@ class FileEventStore {
|
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
fs.mkdirSync(this.eventsDir, { recursive: true });
|
|
70
|
-
|
|
70
|
+
const eventFilePath = this.getEventFilePath(event.aggregateId);
|
|
71
|
+
// 上一次追加被强杀在行中间时,文件末尾是没有换行的残缺行:先截掉它(read 本来也不认它),
|
|
72
|
+
// 否则新事件会拼在它后面,残缺行变成中间行,之后每次 read 都会抛错
|
|
73
|
+
this.dropIncompleteTail(eventFilePath);
|
|
74
|
+
fs.appendFileSync(eventFilePath, `${JSON.stringify(event)}\n`);
|
|
71
75
|
const projection = projectSessionEvents([...events, event]);
|
|
72
76
|
this.saveProjection(event.aggregateId, projection);
|
|
73
77
|
return event;
|
|
74
78
|
}
|
|
75
79
|
|
|
80
|
+
dropIncompleteTail(filePath) {
|
|
81
|
+
if (!fs.existsSync(filePath)) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const content = fs.readFileSync(filePath);
|
|
85
|
+
if (!content.length || content[content.length - 1] === 0x0a) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const lineStart = content.lastIndexOf(0x0a) + 1;
|
|
89
|
+
try {
|
|
90
|
+
JSON.parse(content.subarray(lineStart).toString('utf-8'));
|
|
91
|
+
// 最后一行是完整事件,只是缺换行:补上换行,保留这条事件
|
|
92
|
+
fs.appendFileSync(filePath, '\n');
|
|
93
|
+
} catch (error) {
|
|
94
|
+
fs.truncateSync(filePath, lineStart);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
76
98
|
loadProjection(aggregateId) {
|
|
77
99
|
const events = this.read(aggregateId);
|
|
78
100
|
const projection = projectSessionEvents(events);
|
package/lib/init-config.js
CHANGED
|
@@ -418,6 +418,8 @@ async function initAgentConfigs(rawAgents, options = {}) {
|
|
|
418
418
|
// 只在原文上局部写入各 runs.<agent>,保留用户的注释与格式
|
|
419
419
|
const originalRaw = fs.existsSync(manyoyoConfigPath) ? fs.readFileSync(manyoyoConfigPath, 'utf-8') : '';
|
|
420
420
|
let nextRaw = originalRaw;
|
|
421
|
+
// 记下每次要写的 runs.<agent>:询问期间别处可能改了配置文件,最后在最新内容上重新写,而不是用开始时读到的旧文本覆盖
|
|
422
|
+
const pendingRunWrites = [];
|
|
421
423
|
|
|
422
424
|
let hasConfigChanged = false;
|
|
423
425
|
ctx.log(`${CYAN}🧭 正在初始化 MANYOYO 配置: ${agents.join(', ')}${NC}`);
|
|
@@ -431,7 +433,11 @@ async function initAgentConfigs(rawAgents, options = {}) {
|
|
|
431
433
|
if (shouldWriteRun) {
|
|
432
434
|
const buildResult = buildInitRunProfile(agent, spec.yolo, data.volumes, data.keys, data.values);
|
|
433
435
|
runsMap[agent] = buildResult.runProfile;
|
|
434
|
-
if (originalRaw.trim())
|
|
436
|
+
if (originalRaw.trim()) {
|
|
437
|
+
const profileText = JSON.stringify(buildResult.runProfile, null, 4);
|
|
438
|
+
nextRaw = upsertValueByPath(nextRaw, ['runs', agent], profileText);
|
|
439
|
+
pendingRunWrites.push({ agent, profileText });
|
|
440
|
+
}
|
|
435
441
|
writeResult = { missingKeys: buildResult.missingKeys, unsafeKeys: buildResult.unsafeKeys };
|
|
436
442
|
hasConfigChanged = true;
|
|
437
443
|
}
|
|
@@ -456,7 +462,15 @@ async function initAgentConfigs(rawAgents, options = {}) {
|
|
|
456
462
|
|
|
457
463
|
if (hasConfigChanged || !fs.existsSync(manyoyoConfigPath)) {
|
|
458
464
|
if (originalRaw.trim()) {
|
|
459
|
-
|
|
465
|
+
let finalRaw = nextRaw;
|
|
466
|
+
const latestRaw = fs.existsSync(manyoyoConfigPath) ? fs.readFileSync(manyoyoConfigPath, 'utf-8') : '';
|
|
467
|
+
if (latestRaw !== originalRaw && latestRaw.trim()) {
|
|
468
|
+
finalRaw = latestRaw;
|
|
469
|
+
for (const write of pendingRunWrites) {
|
|
470
|
+
finalRaw = upsertValueByPath(finalRaw, ['runs', write.agent], write.profileText);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
writeConfigFileSecure(manyoyoConfigPath, finalRaw);
|
|
460
474
|
} else {
|
|
461
475
|
// 新文件没有可保留的注释,写标准 JSON
|
|
462
476
|
writeConfigFileSecure(manyoyoConfigPath, `${JSON.stringify({ ...manyoyoConfig, runs: runsMap }, null, 4)}\n`);
|
package/lib/web/AGENTS.md
CHANGED
|
@@ -15,9 +15,14 @@
|
|
|
15
15
|
- `trace`:**不保证带结构化 `traceEvent`**。stderr 行(`emitStderrTraceLine`)和解析不出 JSON 的 stdout 行只有 `text` + `stream`,前端靠 `toTraceEvent()` 兜底成 `kind="output"` 的事件展示。新增只认 `traceEvent` 的分支等于把自定义 CLI、CLI 报错的输出全吞掉。
|
|
16
16
|
- `ping`:保活心跳,前端忽略。`DEFAULT_AGENT_STREAM_HEARTBEAT_MS` 必须明显小于反向代理的空闲读超时(nginx `proxy_read_timeout` 默认 60s),否则 agent 静默期间流会被 RST,浏览器侧表现为输入框上方冒出 `network error`。
|
|
17
17
|
- token 级增量**不写**事件日志、历史落盘按 `AGENT_STREAM_PARTIAL_PERSIST_INTERVAL_MS` 节流;新增逐事件的落盘/日志动作前先想清楚它会不会被每个 token 触发一次。
|
|
18
|
-
- `
|
|
18
|
+
- **历史读写在 `lib/web/history-store.js`**:进程内缓存 + 原子写(`writeConfigFileSecure`,0600)+ 合并写盘。读靠 `stat` 的 `ino + mtimeMs + size` 判断缓存是否还有效(别的进程原子替换也能识别);`loadWebSessionHistory` 返回的是**缓存里的同一个对象**,改了必须 `saveWebSessionHistory`,只读路由不要改它(`getWebAgentSession(..., { create: true })` 会在缓存里留一个未保存的空 agent,只在马上要写的路径里用)。流式过程中的 trace patch 和控制事件走 `{ defer: true }`(只改内存,最迟 `AGENT_STREAM_PARTIAL_PERSIST_INTERVAL_MS` 后落盘);`process.exit` / serve 关闭会 flush;`result` / `error` 类事件(`WEB_STREAM_FLUSH_EVENT_TYPES`)和所有非 defer 写入立即写盘并带上攒着的内容。`kill -9` 最多丢约 1 秒的 trace。文件解析失败会留一份 `.corrupt-<时间>` 备份再按空历史处理,不要绕过它。磁盘格式只有 `agents.<id>.messages`(不再有顶层 `messages`,紧凑 JSON),老版本(含 `update --rollback`)读取时优先用 `agents`,能读新文件。
|
|
19
|
+
- `GET /api/sessions` 以前是同步 IO 大户(逐容器读 + 解析整份历史),现在命中缓存基本是 0 次解析,但新增的同步操作仍要先实测;`buildSessionSummary()` 支持传入调用方加载好的 history,不要重复读。(`docker ps -a` + 批量 `inspect` 实测仅约 90ms,不是瓶颈)
|
|
20
|
+
- **`/messages` 是分页 + 增量**(`lib/web/messages-view.js`):`?limit=`(1~200,默认 60,起点往前对齐到 user 消息)/ `?after=<id>&epoch=<e>`(tail,锚点之后的消息;epoch 不符、锚点不存在或超过 120 条时返回最近一页并标 `mode: "reset"`)/ `?before=<id>`(更早一页);`GET /messages/<id>/trace` 取完整执行过程。查询参数全部白名单校验,非法返回 400。**稳定前缀约定**:第一条 pending 消息之前的消息不再被修改——违反约定的写操作(删除非 pending 消息、克隆)必须给该 agent 换新的 `epoch`(`removeWebSessionMessage` 与 clone 已处理;新增修改历史消息的写操作要自己判断)。500 条上限从头裁掉**不换** epoch(tail 响应带 `firstId`,前端据此裁掉本地被丢弃的开头)。列表里的 trace 消息会被精简(长字符串截成 200 字符,带 `traceTruncated`),`/audit` 与 trace 接口输出完整内容。
|
|
21
|
+
- **控制事件的 seq 以事件日志为准,不是历史快照**:事件日志(`FileEventStore`)每条事件立即追加,历史快照里的 `events` 是合并写盘的,进程被强杀后快照会落后;`appendWebSessionControlEvent` 靠进程内的 `controlEventSeqCache`(首次用从日志读一次,对不上就丢缓存重算)续 seq,从快照取 seq 会在强杀重启后让每一轮对话都抛 `seq 必须连续递增`、serve 直接退出。`FileEventStore.append` 追加前会截掉被强杀留下的残缺末行(否则它变成中间行,之后每次读都抛错)。
|
|
22
|
+
- **`clientMessageId` 幂等**:`/agent/stream`、`/agent`、`/run` 收到的 `clientMessageId`(`^[A-Za-z0-9_-]{8,64}$`)会存进 user 消息;同一个 id 已出现在该 agent 的消息里(或同一条请求还在检查与落盘之间,`claimClientMessage`)就不再执行,返回 409 `{ error, duplicate: true }`(`rejectDuplicateClientMessage`)。前端在网络中断后重发放回的内容时沿用原 id,靠它兜住“服务端其实已经收到”的情况;有 `duplicate` 的 409 与“容器忙”的 409 要区分。
|
|
23
|
+
- **ETag / 压缩**(`sendJson` / `sendHtml` → `sendBody`):`/messages` 的 ETag 是 `bootId + 进程内历史版本号 + 查询摘要`(不用 mtime:同一毫秒内两次写入大小相同会误回 304 显示旧内容);`/sessions`、`detail` 用响应体 sha1;index.html 按文件 stat 缓存并带 ETag。这些响应 `Cache-Control: no-cache`,其余仍是 `no-store`。>1KB 的 JSON/HTML 异步 gzip;**NDJSON 流和 WebSocket 不压缩**(压缩会被缓冲,破坏实时性),`X-Accel-Buffering: no` 必须保留。
|
|
19
24
|
- 请求处理链路上注册在**全局认证网关之前**的代码(如 `res.on('finish')` 之类的钩子)必须自带 try/catch:那里抛异常会冒泡成 `uncaughtException`,而 `bin/manyoyo.js` 的处理器直接 `process.exit(1)`——等于未认证请求可打挂 serve。回归用例见 `test/web-server-auth.test.js` 的 `Web Server Robustness`。
|
|
20
|
-
- **终端 WebSocket**:`/api/sessions/:name/terminal/ws`,在 `server.on('upgrade')` 里做 Origin 校验 + 认证 + 容器名校验,再交给 `bindTerminalWebSocket()`。上行 `input` / `ping` / `close`,下行 `output` / `status`(`ready` / `closed`)/ `error` / `pong`;实现是 `docker exec` 里用 `script` 或 python `pty` 引导出的伪 TTY
|
|
25
|
+
- **终端 WebSocket**:`/api/sessions/:name/terminal/ws`,在 `server.on('upgrade')` 里做 Origin 校验 + 认证 + 容器名校验,再交给 `bindTerminalWebSocket()`。上行 `input` / `resize` / `ping` / `close`,下行 `output` / `status`(`ready` / `closed`)/ `error` / `pong`;实现是 `docker exec` 里用 `script` 或 python `pty` 引导出的伪 TTY。**动态 resize**:bootstrap 的 rc 把 `tty` 输出写进容器里 `/tmp/manyoyo-term-<随机id>.tty`;收到 `resize` 后(整数、10~500 列、4~200 行,否则整条忽略;下限比建连初值 40×12 低,因为手机竖屏不到 40 列;节流,同一时刻只有一个 exec)用固定脚本 `TERMINAL_RESIZE_SCRIPT` 在容器里读该文件,内容必须整行匹配 `/dev/pts/<数字>` 才执行 `stty -F`。**这个 tty 文件容器里的进程可写,只能当不可信输入**(脚本只认普通文件、不跟符号链接、最多读 64 字节),用例见 `test/web-terminal-resize.test.js`。降级成非 TTY 时 resize 自然无效。并发上限 `WEB_TERMINAL_MAX_SESSIONS = 20`,每条连接对应一个 `ptyProcess`,`ws` 关闭即 `SIGTERM`——所以前端任何「卸载终端组件」的改动都等于杀掉用户正在跑的 shell。
|
|
21
26
|
- 空闲保活是 `/agent/stream` 那条心跳规则的同类问题,**两个入口都要守**:终端侧服务端每 `WEB_TERMINAL_PING_INTERVAL_MS`(30s)发 WebSocket ping 帧,连续 `WEB_TERMINAL_MAX_MISSED_PONGS`(3 次,约 90s)无 pong 才判死;前端另发应用层 `ping`(浏览器 JS 发不出 ping 帧,上行需要自己造流量)。判死阈值不要收紧到一个周期:手机切后台会让连接短暂挂起,误杀等于用户的 shell 没了。升级后的 socket 还要 `setTimeout(0)` + `setKeepAlive`,解除 HTTP 侧空闲超时。
|
|
22
27
|
- 新增接口/页面必须走全局认证网关,禁止在业务路由里零散补认证;匿名白名单见根 `AGENTS.md` 的安全约束。
|
|
23
28
|
|
|
@@ -25,10 +30,16 @@
|
|
|
25
30
|
|
|
26
31
|
`lib/web/container-manage.js`(路由工厂,由 `handleWebApi` 追加进路由表):`/api/containers/:name/{env,autostart,autostart/run,autostart/log,network,network/allow,network/denied,expose}` 与 `/api/containers/orphans`。旧容器(无 `manyoyo.id`)GET 返回 `{legacy:true}`、写返回 409。`PUT env` 用 `If-Match`(etag)防止覆盖容器内的修改;放开类改动(切到 `custom`、新增很宽的允许规则、非 loopback 暴露)服务端也要求 `confirmRisk:true`;`network` 是 v2 规则表(`outbound` / `inbound`,旧 v1 读取时自动转换,`@container:<名称>` 保存时换成 id)。serve 运行时就绪后 `startContainerServices` 订阅 `<runtime> events`(start / die)补下发规则、拉起 `autostartOnServe` 容器、恢复端口暴露;事件流断开重连(运行时重启)后 `reconcileContainerServices` 再对账一次;过滤代理是 sidecar 容器(`lib/egress-sidecar.js`),由网络管理器 `ensure()` 保证在运行,不在 serve 进程里。`GET network` 带 `denied`(最近被拦截,已放行的不列)、`derived`(别的容器入站放行本容器产生的出站派生行)与 `peers[].ip`;`network/allow` 在出站用户规则最前插入允许域名行;`/agent/stream` 进行中 `createWebDeniedNotifier` 每 2 秒读一次拦截记录,有新域名就推一条 `trace`(只有 `text`,同一域名同一轮一次)。所有 exec 经 `spawnContainerExec`(env 临时文件在子进程 close 时删)。
|
|
27
32
|
|
|
33
|
+
## 保存不覆盖别人的修改(If-Match)
|
|
34
|
+
|
|
35
|
+
`PUT /api/config` 和 `PUT /api/sessions/:name/fs/write` **必须**带 `If-Match`,缺少返回 428(报错统一用 `STALE_PAGE_MESSAGE`“页面已更新,请刷新页面后再保存”:现在的前端总会带上 If-Match,走到这里的只有升级前就打开、还没刷新的旧页面,提示要让用户知道怎么办),版本不一致返回 409 `{ conflict: true }`(与容器环境变量 `PUT env` 同一套约定)。`GET /api/config` 的 `etag` 是**磁盘原文**的 sha256(不是脱敏后的 `raw`:只改了密钥时脱敏文本不变,用它算版本就识别不出来),响应里只有哈希;409 的响应体带最新的 `buildSafeWebConfigSnapshot`(含新 `etag`)。配置文件的“读文件 → 比对版本 → 写文件”必须在同一个同步段里完成(中间不能有 `await`,并发请求测试 `test/web-config-etag.test.js` 守着)。`fs/read` 返回内容的 sha256 `etag`(只给 2MB 以内的可编辑文件算),`fs/write` 在容器内的脚本里写入前重新算当前内容的哈希,不一致就返回 `conflict`、服务端转成 409;容器内“比对”和“写入”之间仍有一个极短的窗口(同一个脚本里的两条语句),可以接受。其余写配置文件的接口(`/api/setup/*`、`quick-chat-config`、CLI 的 `build`/`init`/`setup`)都是“读当前磁盘内容 → 局部修改 → 写回”,不会盖掉别人的修改,不需要版本号。**新增写配置文件的接口时,要么走局部修改,要么也带 If-Match。**
|
|
36
|
+
|
|
28
37
|
## lib/web/index.html
|
|
29
38
|
|
|
30
|
-
前端构建产物(`npm run build:web`,已 `.gitignore`),由 `loadIndexHtml()`
|
|
39
|
+
前端构建产物(`npm run build:web`,已 `.gitignore`),由 `loadIndexHtml()` 按文件 stat 缓存读取(文件变了才重读),`/` 与登录页 `/auth/login` 共用。服务端不托管其它前端静态资源,别往 `lib/web/` 里放散装 js/css。
|
|
31
40
|
|
|
32
41
|
## 测试
|
|
33
42
|
|
|
43
|
+
- 浏览器 E2E:本机有 `http_proxy` 时,连本地 CDP / serve 要 `env -u http_proxy` 或 `--noproxy '*'`;Playwright `connectOverCDP` 的页面始终显示为有焦点、可见,焦点和可见性要靠覆写 `document.hasFocus` / `visibilityState` 并派发事件来模拟;假 Agent 的开关用唯一令牌,因为服务端会把最近的对话注入 prompt。
|
|
44
|
+
|
|
34
45
|
Web 相关改动优先补 `test/web-server-auth.test.js`;涉及认证时至少验证未登录 `401`、登录成功可访问、登出后失效。
|
|
@@ -14,6 +14,8 @@ const { isBrowserBackground } = require('../browser-background');
|
|
|
14
14
|
// 容器管理接口:环境变量、自启动、网络(含端口暴露)、孤儿状态目录。
|
|
15
15
|
// 全部挂在全局认证网关之后;非只读请求由 handleWebApi 统一校验 X-Requested-With。
|
|
16
16
|
const LEGACY_MESSAGE = '该容器创建于旧版本,重建后可用';
|
|
17
|
+
// 写接口缺 If-Match 只会发生在升级前就打开、还没刷新的旧页面上:告诉用户怎么办,不出现 If-Match / etag 这类术语
|
|
18
|
+
const STALE_PAGE_MESSAGE = '页面已更新,请刷新页面后再保存';
|
|
17
19
|
|
|
18
20
|
// 本机可用于访问端口暴露的 IPv4 地址(绑定 0.0.0.0 时前端据此列出全部访问链接);
|
|
19
21
|
// 回环排第一,容器 / 虚拟网桥的地址对访问者没用,过滤掉
|
|
@@ -389,7 +391,7 @@ function createContainerManageRoutes({ ctx, state, req, res, deps }) {
|
|
|
389
391
|
}
|
|
390
392
|
const ifMatch = req.headers['if-match'];
|
|
391
393
|
if (ifMatch === undefined) {
|
|
392
|
-
sendJson(res, 428, { error:
|
|
394
|
+
sendJson(res, 428, { error: STALE_PAGE_MESSAGE });
|
|
393
395
|
return;
|
|
394
396
|
}
|
|
395
397
|
try {
|
|
@@ -557,4 +559,4 @@ function createContainerManageRoutes({ ctx, state, req, res, deps }) {
|
|
|
557
559
|
];
|
|
558
560
|
}
|
|
559
561
|
|
|
560
|
-
module.exports = { createContainerManageRoutes, pendingRisks, isLoopbackBind };
|
|
562
|
+
module.exports = { createContainerManageRoutes, pendingRisks, isLoopbackBind, STALE_PAGE_MESSAGE };
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const crypto = require('crypto');
|
|
6
|
+
const { writeConfigFileSecure } = require('../secure-file');
|
|
7
|
+
|
|
8
|
+
// 历史文件的进程内缓存 + 原子写 + 合并写盘。
|
|
9
|
+
// - 读:stat 的 ino + mtimeMs + size 没变就直接返回缓存对象(原子写每次都会换 inode,别的进程改了也能认出来)。
|
|
10
|
+
// - 版本号:进程内单调计数,加启动时随机的 bootId,供 ETag 使用;不能只用 mtime(同一毫秒内的两次写入会撞)。
|
|
11
|
+
// - 写:默认立即原子写;defer 写只改内存并最迟 DEFER_FLUSH_MS 后落盘,serve 关闭时 flushAll。
|
|
12
|
+
|
|
13
|
+
const CACHE_MAX_BYTES = 64 * 1024 * 1024;
|
|
14
|
+
const DEFER_FLUSH_MS = 1000;
|
|
15
|
+
|
|
16
|
+
const bootId = crypto.randomBytes(3).toString('hex');
|
|
17
|
+
const cache = new Map();
|
|
18
|
+
// 版本号单独记:缓存条目被淘汰后 ETag 的版本号也不能丢(否则内容变了版本还是 0)
|
|
19
|
+
const versions = new Map();
|
|
20
|
+
const backedUp = new Set();
|
|
21
|
+
let versionSeq = 0;
|
|
22
|
+
let flushTimer = null;
|
|
23
|
+
|
|
24
|
+
function nextVersion(filePath) {
|
|
25
|
+
versionSeq += 1;
|
|
26
|
+
versions.set(filePath, versionSeq);
|
|
27
|
+
return versionSeq;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function statKey(stat) {
|
|
31
|
+
return `${stat.ino}:${stat.mtimeMs}:${stat.size}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function evictIfNeeded(keep) {
|
|
35
|
+
let total = 0;
|
|
36
|
+
cache.forEach(entry => { total += entry.bytes; });
|
|
37
|
+
if (total <= CACHE_MAX_BYTES) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const candidates = [...cache.entries()]
|
|
41
|
+
.filter(([key, entry]) => !entry.dirty && key !== keep)
|
|
42
|
+
.sort((a, b) => a[1].lastUsed - b[1].lastUsed);
|
|
43
|
+
for (const [key, entry] of candidates) {
|
|
44
|
+
if (total <= CACHE_MAX_BYTES) {
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
cache.delete(key);
|
|
48
|
+
total -= entry.bytes;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function touch(entry) {
|
|
53
|
+
entry.lastUsed = Date.now();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @param {string} filePath
|
|
58
|
+
* @param {(raw: any) => any} normalize 把解析出的 JSON 归一化为内存对象
|
|
59
|
+
* @returns {{ value: any, version: number }|null} 文件不存在返回 null
|
|
60
|
+
*/
|
|
61
|
+
function read(filePath, normalize) {
|
|
62
|
+
let stat = null;
|
|
63
|
+
try {
|
|
64
|
+
stat = fs.statSync(filePath);
|
|
65
|
+
} catch (e) {
|
|
66
|
+
stat = null;
|
|
67
|
+
}
|
|
68
|
+
const entry = cache.get(filePath);
|
|
69
|
+
if (entry && entry.dirty) {
|
|
70
|
+
// 内存里有尚未落盘的更新,比磁盘新
|
|
71
|
+
touch(entry);
|
|
72
|
+
return { value: entry.value, version: entry.version };
|
|
73
|
+
}
|
|
74
|
+
if (!stat) {
|
|
75
|
+
cache.delete(filePath);
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
if (entry && entry.key === statKey(stat)) {
|
|
79
|
+
touch(entry);
|
|
80
|
+
return { value: entry.value, version: entry.version };
|
|
81
|
+
}
|
|
82
|
+
const text = fs.readFileSync(filePath, 'utf-8');
|
|
83
|
+
let parsed = null;
|
|
84
|
+
try {
|
|
85
|
+
parsed = JSON.parse(text);
|
|
86
|
+
} catch (e) {
|
|
87
|
+
// 解析失败:留一份备份,免得下一次保存把空历史写回去后原文彻底丢失(同一份损坏内容只备份一次)
|
|
88
|
+
const corruptKey = `${filePath}|${statKey(stat)}`;
|
|
89
|
+
if (!backedUp.has(corruptKey)) {
|
|
90
|
+
backedUp.add(corruptKey);
|
|
91
|
+
try {
|
|
92
|
+
fs.copyFileSync(filePath, `${filePath}.corrupt-${Date.now()}`);
|
|
93
|
+
} catch (copyError) { /* 尽力 */ }
|
|
94
|
+
}
|
|
95
|
+
cache.delete(filePath);
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
const value = normalize(parsed);
|
|
99
|
+
const next = {
|
|
100
|
+
value,
|
|
101
|
+
version: nextVersion(filePath),
|
|
102
|
+
key: statKey(stat),
|
|
103
|
+
bytes: stat.size,
|
|
104
|
+
dirty: false,
|
|
105
|
+
lastUsed: Date.now(),
|
|
106
|
+
serialize: null
|
|
107
|
+
};
|
|
108
|
+
cache.set(filePath, next);
|
|
109
|
+
evictIfNeeded(filePath);
|
|
110
|
+
return { value, version: next.version };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function writeEntry(filePath, entry) {
|
|
114
|
+
const text = entry.serialize(entry.value);
|
|
115
|
+
writeConfigFileSecure(filePath, text);
|
|
116
|
+
const stat = fs.statSync(filePath);
|
|
117
|
+
entry.key = statKey(stat);
|
|
118
|
+
entry.bytes = stat.size;
|
|
119
|
+
entry.dirty = false;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function scheduleFlush() {
|
|
123
|
+
if (flushTimer) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
flushTimer = setTimeout(() => {
|
|
127
|
+
flushTimer = null;
|
|
128
|
+
flushAll();
|
|
129
|
+
}, DEFER_FLUSH_MS);
|
|
130
|
+
if (typeof flushTimer.unref === 'function') {
|
|
131
|
+
flushTimer.unref();
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* @param {string} filePath
|
|
137
|
+
* @param {any} value 已归一化的内存对象(成为新的缓存值)
|
|
138
|
+
* @param {(value: any) => string} serialize
|
|
139
|
+
* @param {{ defer?: boolean }} [options]
|
|
140
|
+
* @returns {number} 新版本号
|
|
141
|
+
*/
|
|
142
|
+
function write(filePath, value, serialize, options = {}) {
|
|
143
|
+
const entry = {
|
|
144
|
+
value,
|
|
145
|
+
version: nextVersion(filePath),
|
|
146
|
+
key: '',
|
|
147
|
+
bytes: 0,
|
|
148
|
+
dirty: true,
|
|
149
|
+
lastUsed: Date.now(),
|
|
150
|
+
serialize
|
|
151
|
+
};
|
|
152
|
+
const previous = cache.get(filePath);
|
|
153
|
+
if (previous) {
|
|
154
|
+
entry.bytes = previous.bytes;
|
|
155
|
+
}
|
|
156
|
+
cache.set(filePath, entry);
|
|
157
|
+
if (options.defer === true) {
|
|
158
|
+
scheduleFlush();
|
|
159
|
+
return entry.version;
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
writeEntry(filePath, entry);
|
|
163
|
+
} catch (error) {
|
|
164
|
+
cache.delete(filePath);
|
|
165
|
+
throw error;
|
|
166
|
+
}
|
|
167
|
+
evictIfNeeded(filePath);
|
|
168
|
+
return entry.version;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** 立即写出所有待写内容;目录已不存在(如测试清理后)则放弃,不重新建目录 */
|
|
172
|
+
function flushAll() {
|
|
173
|
+
let failed = false;
|
|
174
|
+
cache.forEach((entry, filePath) => {
|
|
175
|
+
if (!entry.dirty) {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (!fs.existsSync(path.dirname(filePath))) {
|
|
179
|
+
cache.delete(filePath);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
writeEntry(filePath, entry);
|
|
184
|
+
} catch (error) {
|
|
185
|
+
failed = true;
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
if (failed) {
|
|
189
|
+
// 写失败(磁盘满、权限):稍后再试,不要等到下一次写入
|
|
190
|
+
scheduleFlush();
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function flushFile(filePath) {
|
|
195
|
+
const entry = cache.get(filePath);
|
|
196
|
+
if (entry && entry.dirty) {
|
|
197
|
+
writeEntry(filePath, entry);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function remove(filePath) {
|
|
202
|
+
cache.delete(filePath);
|
|
203
|
+
versions.delete(filePath);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function getVersion(filePath) {
|
|
207
|
+
return versions.get(filePath) || 0;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// 进程退出(含信号处理里的 process.exit)前写出待写内容
|
|
211
|
+
process.on('exit', flushAll);
|
|
212
|
+
|
|
213
|
+
module.exports = {
|
|
214
|
+
bootId,
|
|
215
|
+
read,
|
|
216
|
+
write,
|
|
217
|
+
flushAll,
|
|
218
|
+
flushFile,
|
|
219
|
+
remove,
|
|
220
|
+
getVersion
|
|
221
|
+
};
|