@xcanwin/manyoyo 8.7.2 → 8.7.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/README.md +1 -1
- package/bin/manyoyo.js +31 -1
- package/lib/archive/export.js +564 -0
- package/lib/archive/import.js +521 -0
- package/lib/archive/plan.js +197 -0
- package/lib/archive/risks.js +121 -0
- package/lib/archive/runtime.js +72 -0
- package/lib/archive/safe-extract.js +163 -0
- package/lib/archive/tar.js +329 -0
- package/lib/model-services.js +9 -3
- package/lib/setup.js +2 -2
- package/lib/web/AGENTS.md +1 -0
- package/lib/web/container-jobs.js +276 -0
- package/lib/web/index.html +86 -85
- package/lib/web/server.js +233 -7
- package/lib/web/transfer-routes.js +574 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<p align="center">
|
|
2
|
-
<img src="./assets/manyoyo-
|
|
2
|
+
<img src="./assets/manyoyo-robots-fighting.png" alt="MANYOYO logo" width="720" />
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
5
|
# <p align="center"><a href="https://github.com/xcanwin/manyoyo">MANYOYO(慢悠悠)</a></p>
|
package/bin/manyoyo.js
CHANGED
|
@@ -1394,7 +1394,7 @@ https://github.com/xcanwin/manyoyo
|
|
|
1394
1394
|
`);
|
|
1395
1395
|
applyRunStyleOptions(serveCommand, { includeRmOnExit: false, includeWebAuthOptions: true });
|
|
1396
1396
|
serveCommand.option('-d, --detach', '后台启动网页服务并立即返回');
|
|
1397
|
-
serveCommand.option('--stop', '
|
|
1397
|
+
serveCommand.option('--stop', '停止后台网页服务;不传 listen 时停止本机所有正在运行的网页服务');
|
|
1398
1398
|
serveCommand.option('--restart', '重启后台网页服务;必须显式传入 listen');
|
|
1399
1399
|
serveCommand.option('--list', '列出正在运行的网页服务(监听地址、PID、版本、启动命令)');
|
|
1400
1400
|
serveCommand.action((listen, options) => {
|
|
@@ -2014,8 +2014,38 @@ function writeServePidFile(runtime, serverHandle) {
|
|
|
2014
2014
|
return pidFile.path;
|
|
2015
2015
|
}
|
|
2016
2016
|
|
|
2017
|
+
// manyoyo serve --stop(不带 listen):停掉本机所有正在运行的 serve 实例
|
|
2018
|
+
async function stopAllServeProcesses() {
|
|
2019
|
+
const instances = listServeInstances();
|
|
2020
|
+
if (!instances.length) {
|
|
2021
|
+
console.log(`${YELLOW}⚠️ 没有运行中的 serve 实例${NC}`);
|
|
2022
|
+
return 0;
|
|
2023
|
+
}
|
|
2024
|
+
let stopped = 0;
|
|
2025
|
+
for (const instance of instances) {
|
|
2026
|
+
const cut = instance.listen.lastIndexOf(':');
|
|
2027
|
+
const host = instance.listen.slice(0, cut).replace(/^\[|\]$/g, '');
|
|
2028
|
+
const port = Number(instance.listen.slice(cut + 1));
|
|
2029
|
+
const target = getServePidTarget(host, port);
|
|
2030
|
+
const pid = target ? target.pid : instance.pid;
|
|
2031
|
+
try { process.kill(pid, 'SIGTERM'); } catch (e) { if (!e || e.code !== 'ESRCH') throw e; }
|
|
2032
|
+
await sleep(200);
|
|
2033
|
+
if (isProcessRunning(pid)) {
|
|
2034
|
+
try { process.kill(pid, 'SIGKILL'); } catch (e) { if (!e || e.code !== 'ESRCH') throw e; }
|
|
2035
|
+
}
|
|
2036
|
+
if (target) removeServePidFile(target.path);
|
|
2037
|
+
console.log(`${GREEN}✅ 已停止 serve: ${instance.listen} (pid: ${pid})${NC}`);
|
|
2038
|
+
stopped += 1;
|
|
2039
|
+
}
|
|
2040
|
+
return stopped;
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2017
2043
|
async function stopServeProcess(runtime, options = {}) {
|
|
2018
2044
|
const commandName = options.commandName || '--stop';
|
|
2045
|
+
if (commandName === '--stop' && runtime && !runtime.serverListenSpecified) {
|
|
2046
|
+
await stopAllServeProcesses();
|
|
2047
|
+
return true;
|
|
2048
|
+
}
|
|
2019
2049
|
if (!runtime || !runtime.serverListenSpecified) {
|
|
2020
2050
|
throw new Error(`serve ${commandName} 必须显式传入 listen,例如 manyoyo serve 127.0.0.1:3000 ${commandName}`);
|
|
2021
2051
|
}
|
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// 容器导出:把若干容器的聊天记录、设置、Agent 数据、工作目录等写成一个 tar(可压缩)。
|
|
4
|
+
// 容器里的数据用 `cp <容器>:<路径> -` 取,再按 `diff`(相对镜像的改动)过滤,所以镜像自带的内容和缓存不会进归档。
|
|
5
|
+
// 宿主机侧(状态目录、工作目录)里的内容是容器可写的,按不可信输入处理:lstat 遍历,不跟随符号链接,
|
|
6
|
+
// 跳过 FIFO / 设备 / socket,不跨文件系统,打开时 O_NOFOLLOW。
|
|
7
|
+
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const zlib = require('zlib');
|
|
11
|
+
const { PassThrough, pipeline } = require('stream');
|
|
12
|
+
const { once } = require('events');
|
|
13
|
+
|
|
14
|
+
const { encodeEntryHeader, readTarEntries, END_BLOCKS, pad } = require('./tar');
|
|
15
|
+
const plan = require('./plan');
|
|
16
|
+
const envText = require('../env-text');
|
|
17
|
+
const { summarizeRisks } = require('./risks');
|
|
18
|
+
|
|
19
|
+
const O_NOFOLLOW = fs.constants.O_NOFOLLOW || 0;
|
|
20
|
+
const O_NONBLOCK = fs.constants.O_NONBLOCK || 0;
|
|
21
|
+
const MAX_SMALL_FILE = 64 * 1024 * 1024;
|
|
22
|
+
|
|
23
|
+
class ExportCancelled extends Error {
|
|
24
|
+
constructor() {
|
|
25
|
+
super('导出已取消');
|
|
26
|
+
this.name = 'ExportCancelled';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// 等 drain;输出被关掉(客户端断开等)时立即报错,而不是永远等下去
|
|
31
|
+
function waitDrain(sink) {
|
|
32
|
+
return new Promise((resolve, reject) => {
|
|
33
|
+
const cleanup = () => {
|
|
34
|
+
sink.removeListener('drain', onDrain);
|
|
35
|
+
sink.removeListener('close', onClose);
|
|
36
|
+
sink.removeListener('error', onError);
|
|
37
|
+
};
|
|
38
|
+
const onDrain = () => { cleanup(); resolve(); };
|
|
39
|
+
const onClose = () => { cleanup(); reject(new Error('输出已关闭(客户端可能已断开)')); };
|
|
40
|
+
const onError = error => { cleanup(); reject(error); };
|
|
41
|
+
if (sink.destroyed) return onClose();
|
|
42
|
+
sink.once('drain', onDrain);
|
|
43
|
+
sink.once('close', onClose);
|
|
44
|
+
sink.once('error', onError);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 带背压的 tar 写入器
|
|
49
|
+
class TarWriter {
|
|
50
|
+
constructor(sink, hooks = {}) {
|
|
51
|
+
this.sink = sink;
|
|
52
|
+
this.bytes = 0;
|
|
53
|
+
this.entries = 0;
|
|
54
|
+
this.hooks = hooks;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async raw(buffer) {
|
|
58
|
+
this.hooks.check && this.hooks.check();
|
|
59
|
+
this.bytes += buffer.length;
|
|
60
|
+
if (!this.sink.write(buffer)) await waitDrain(this.sink);
|
|
61
|
+
this.hooks.onBytes && this.hooks.onBytes(buffer.length);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async addBuffer(name, buffer, meta = {}) {
|
|
65
|
+
const body = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer);
|
|
66
|
+
await this.raw(encodeEntryHeader({ name, type: 'file', size: body.length, mode: meta.mode, mtime: meta.mtime }));
|
|
67
|
+
if (body.length) await this.raw(body);
|
|
68
|
+
const padding = pad(body.length);
|
|
69
|
+
if (padding) await this.raw(Buffer.alloc(padding));
|
|
70
|
+
this.entries += 1;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async addDir(name, meta = {}) {
|
|
74
|
+
await this.raw(encodeEntryHeader({ name, type: 'dir', mode: meta.mode, mtime: meta.mtime }));
|
|
75
|
+
this.entries += 1;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async addSymlink(name, linkname, meta = {}) {
|
|
79
|
+
await this.raw(encodeEntryHeader({ name, type: 'symlink', linkname, mode: meta.mode, mtime: meta.mtime }));
|
|
80
|
+
this.entries += 1;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 已知大小的流式文件。流提前结束(文件被截短)用 0 补齐,保证 tar 结构正确
|
|
84
|
+
async addStream(name, size, iterable, meta = {}) {
|
|
85
|
+
await this.raw(encodeEntryHeader({ name, type: 'file', size, mode: meta.mode, mtime: meta.mtime }));
|
|
86
|
+
let written = 0;
|
|
87
|
+
for await (const chunk of iterable) {
|
|
88
|
+
const room = size - written;
|
|
89
|
+
if (room <= 0) break;
|
|
90
|
+
const piece = chunk.length > room ? chunk.subarray(0, room) : chunk;
|
|
91
|
+
await this.raw(piece);
|
|
92
|
+
written += piece.length;
|
|
93
|
+
}
|
|
94
|
+
if (written < size) await this.raw(Buffer.alloc(size - written));
|
|
95
|
+
const padding = pad(size);
|
|
96
|
+
if (padding) await this.raw(Buffer.alloc(padding));
|
|
97
|
+
this.entries += 1;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async finish() {
|
|
101
|
+
await this.raw(END_BLOCKS);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function zstdAvailable() {
|
|
106
|
+
return typeof zlib.createZstdCompress === 'function';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// 压缩档:fast = zstd-3(没有 zstd 的 Node 退回 gzip-6);small = zstd-10 / gzip-9
|
|
110
|
+
function createCompressor(mode) {
|
|
111
|
+
if (zstdAvailable()) {
|
|
112
|
+
const level = mode === 'small' ? 10 : 3;
|
|
113
|
+
return {
|
|
114
|
+
ext: 'tar.zst',
|
|
115
|
+
stream: zlib.createZstdCompress({ params: { [zlib.constants.ZSTD_c_compressionLevel]: level, [zlib.constants.ZSTD_c_checksumFlag]: 1 } })
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
return { ext: 'tar.gz', stream: zlib.createGzip({ level: mode === 'small' ? 9 : 6 }) };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function archiveFileName(date = new Date()) {
|
|
122
|
+
const pad2 = n => String(n).padStart(2, '0');
|
|
123
|
+
const stamp = `${date.getFullYear()}${pad2(date.getMonth() + 1)}${pad2(date.getDate())}-${pad2(date.getHours())}${pad2(date.getMinutes())}${pad2(date.getSeconds())}`;
|
|
124
|
+
return `manyoyo-export-${stamp}.${zstdAvailable() ? 'tar.zst' : 'tar.gz'}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---------- 宿主机侧:安全读取 ----------
|
|
128
|
+
|
|
129
|
+
// 读一个小文件(容器可写目录里的),不跟随符号链接,限制大小,非普通文件返回 null
|
|
130
|
+
function readSmallFileSafe(file, maxBytes = MAX_SMALL_FILE) {
|
|
131
|
+
let fd;
|
|
132
|
+
try {
|
|
133
|
+
fd = fs.openSync(file, fs.constants.O_RDONLY | O_NOFOLLOW | O_NONBLOCK);
|
|
134
|
+
} catch (error) {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
const stat = fs.fstatSync(fd);
|
|
139
|
+
if (!stat.isFile() || stat.size > maxBytes) return null;
|
|
140
|
+
const buffer = Buffer.alloc(stat.size);
|
|
141
|
+
let offset = 0;
|
|
142
|
+
while (offset < stat.size) {
|
|
143
|
+
const n = fs.readSync(fd, buffer, offset, stat.size - offset, offset);
|
|
144
|
+
if (n === 0) break;
|
|
145
|
+
offset += n;
|
|
146
|
+
}
|
|
147
|
+
return buffer.subarray(0, offset);
|
|
148
|
+
} finally {
|
|
149
|
+
fs.closeSync(fd);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 单个宿主机文件:不跟随符号链接,不是普通文件就跳过
|
|
154
|
+
async function addHostFile(writer, full, entryName) {
|
|
155
|
+
let fd;
|
|
156
|
+
try {
|
|
157
|
+
fd = fs.openSync(full, fs.constants.O_RDONLY | O_NOFOLLOW | O_NONBLOCK);
|
|
158
|
+
} catch (error) {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
const stat = fs.fstatSync(fd);
|
|
163
|
+
if (!stat.isFile()) return false;
|
|
164
|
+
await writer.addStream(entryName, stat.size, fileChunks(fd, stat.size), { mode: stat.mode, mtime: stat.mtimeMs / 1000 });
|
|
165
|
+
return true;
|
|
166
|
+
} finally {
|
|
167
|
+
fs.closeSync(fd);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function* fileChunks(fd, size) {
|
|
172
|
+
const chunk = Buffer.alloc(256 * 1024);
|
|
173
|
+
let position = 0;
|
|
174
|
+
while (position < size) {
|
|
175
|
+
const want = Math.min(chunk.length, size - position);
|
|
176
|
+
const n = await new Promise((resolve, reject) => fs.read(fd, chunk, 0, want, position, (err, bytes) => (err ? reject(err) : resolve(bytes))));
|
|
177
|
+
if (n === 0) return;
|
|
178
|
+
position += n;
|
|
179
|
+
yield Buffer.from(chunk.subarray(0, n));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* 遍历宿主机目录写入归档。prefix:归档里的路径前缀(不带结尾斜杠)。
|
|
185
|
+
* 符号链接按链接本身存、不跟随;FIFO / 设备 / socket 跳过并计数;不进入另一个文件系统。
|
|
186
|
+
*/
|
|
187
|
+
async function addHostTree(writer, root, prefix, stats, hooks = {}) {
|
|
188
|
+
const lstat = hooks.lstat || fs.lstatSync;
|
|
189
|
+
let rootStat;
|
|
190
|
+
try {
|
|
191
|
+
rootStat = lstat(root);
|
|
192
|
+
} catch (error) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (!rootStat.isDirectory()) return;
|
|
196
|
+
await writer.addDir(prefix, { mode: rootStat.mode, mtime: rootStat.mtimeMs / 1000 });
|
|
197
|
+
const dev = rootStat.dev;
|
|
198
|
+
const stack = [{ dir: root, rel: prefix }];
|
|
199
|
+
while (stack.length) {
|
|
200
|
+
const { dir, rel } = stack.pop();
|
|
201
|
+
let names;
|
|
202
|
+
try {
|
|
203
|
+
names = fs.readdirSync(dir).sort();
|
|
204
|
+
} catch (error) {
|
|
205
|
+
stats.skippedUnreadable = (stats.skippedUnreadable || 0) + 1;
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
for (const name of names) {
|
|
209
|
+
hooks.check && hooks.check();
|
|
210
|
+
const full = path.join(dir, name);
|
|
211
|
+
const entryName = `${rel}/${name}`;
|
|
212
|
+
let st;
|
|
213
|
+
try {
|
|
214
|
+
st = lstat(full);
|
|
215
|
+
} catch (error) {
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if (st.isSymbolicLink()) {
|
|
219
|
+
let target = '';
|
|
220
|
+
try { target = fs.readlinkSync(full); } catch (error) { continue; }
|
|
221
|
+
await writer.addSymlink(entryName, target, { mode: st.mode, mtime: st.mtimeMs / 1000 });
|
|
222
|
+
stats.symlinks = (stats.symlinks || 0) + 1;
|
|
223
|
+
} else if (st.isDirectory()) {
|
|
224
|
+
if (st.dev !== dev) {
|
|
225
|
+
stats.skippedOtherFs = (stats.skippedOtherFs || 0) + 1;
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
await writer.addDir(entryName, { mode: st.mode, mtime: st.mtimeMs / 1000 });
|
|
229
|
+
stack.push({ dir: full, rel: entryName });
|
|
230
|
+
} else if (st.isFile()) {
|
|
231
|
+
if (st.dev !== dev) {
|
|
232
|
+
stats.skippedOtherFs = (stats.skippedOtherFs || 0) + 1;
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
let fd;
|
|
236
|
+
try {
|
|
237
|
+
fd = fs.openSync(full, fs.constants.O_RDONLY | O_NOFOLLOW | O_NONBLOCK);
|
|
238
|
+
} catch (error) {
|
|
239
|
+
stats.skippedUnreadable = (stats.skippedUnreadable || 0) + 1;
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
try {
|
|
243
|
+
const fst = fs.fstatSync(fd);
|
|
244
|
+
if (!fst.isFile() || fst.ino !== st.ino || fst.dev !== st.dev) {
|
|
245
|
+
stats.skippedSpecial = (stats.skippedSpecial || 0) + 1;
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
await writer.addStream(entryName, fst.size, fileChunks(fd, fst.size), { mode: fst.mode, mtime: fst.mtimeMs / 1000 });
|
|
249
|
+
stats.files = (stats.files || 0) + 1;
|
|
250
|
+
stats.bytes = (stats.bytes || 0) + fst.size;
|
|
251
|
+
} finally {
|
|
252
|
+
fs.closeSync(fd);
|
|
253
|
+
}
|
|
254
|
+
} else {
|
|
255
|
+
stats.skippedSpecial = (stats.skippedSpecial || 0) + 1;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ---------- 密钥 ----------
|
|
262
|
+
|
|
263
|
+
function redactEnvMap(map) {
|
|
264
|
+
if (!map || typeof map !== 'object') return map;
|
|
265
|
+
const out = {};
|
|
266
|
+
for (const [key, value] of Object.entries(map)) out[key] = plan.isSecretName(key) ? '' : value;
|
|
267
|
+
return out;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function redactHistory(history) {
|
|
271
|
+
const copy = JSON.parse(JSON.stringify(history));
|
|
272
|
+
const snap = copy.runtimeSnapshot;
|
|
273
|
+
if (snap && typeof snap === 'object') {
|
|
274
|
+
snap.env = redactEnvMap(snap.env);
|
|
275
|
+
if (snap.first && typeof snap.first === 'object') snap.first.env = redactEnvMap(snap.first.env);
|
|
276
|
+
// 命令和脚本里可能直接写着 NAME=令牌
|
|
277
|
+
for (const key of ['autostart', 'agentPromptCommand', 'shellPrefix', 'shell', 'shellSuffix']) {
|
|
278
|
+
if (typeof snap[key] === 'string') snap[key] = plan.redactCommandText(snap[key]);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
if (typeof copy.agentPromptCommand === 'string') copy.agentPromptCommand = plan.redactCommandText(copy.agentPromptCommand);
|
|
282
|
+
if (copy.applied && typeof copy.applied === 'object') {
|
|
283
|
+
for (const key of ['agentPromptCommand', 'shellPrefix', 'shell', 'shellSuffix']) {
|
|
284
|
+
if (typeof copy.applied[key] === 'string') copy.applied[key] = plan.redactCommandText(copy.applied[key]);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return copy;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function redactEnvText(text) {
|
|
291
|
+
const { entries } = envText.parseEnvText(text);
|
|
292
|
+
return envText.serializeEnvEntries(entries.map(item => ({ key: item.key, value: plan.isSecretName(item.key) ? '' : item.value })));
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ---------- 容器里的数据 ----------
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* 从 cp 流里取需要的条目写进归档。
|
|
299
|
+
* scope:容器内 cp 的路径;keep(absPath) 决定是否保留。
|
|
300
|
+
*/
|
|
301
|
+
async function addContainerScope({ writer, runtime, name, scope, prefix, keep, stats, hooks }) {
|
|
302
|
+
const cp = runtime.cp(name, scope);
|
|
303
|
+
const parent = path.posix.dirname(scope);
|
|
304
|
+
try {
|
|
305
|
+
for await (const { meta, body } of readTarEntries(cp.stream)) {
|
|
306
|
+
hooks.check && hooks.check();
|
|
307
|
+
const abs = plan.normalizeContainerPath(path.posix.join(parent, meta.name));
|
|
308
|
+
if (!abs || !plan.isUnder(abs, scope) || !keep(abs, meta)) {
|
|
309
|
+
stats.layerDropped = (stats.layerDropped || 0) + 1;
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
const entryName = `${prefix}/${abs.replace(/^\/+/, '')}`;
|
|
313
|
+
if (meta.type === 'file') {
|
|
314
|
+
await writer.addStream(entryName, meta.size, body, { mode: meta.mode, mtime: meta.mtime });
|
|
315
|
+
stats.layerFiles = (stats.layerFiles || 0) + 1;
|
|
316
|
+
stats.layerBytes = (stats.layerBytes || 0) + meta.size;
|
|
317
|
+
} else if (meta.type === 'dir') {
|
|
318
|
+
await writer.addDir(entryName, { mode: meta.mode, mtime: meta.mtime });
|
|
319
|
+
} else if (meta.type === 'symlink') {
|
|
320
|
+
await writer.addSymlink(entryName, meta.linkname, { mode: meta.mode, mtime: meta.mtime });
|
|
321
|
+
} else {
|
|
322
|
+
stats.layerSkippedSpecial = (stats.layerSkippedSpecial || 0) + 1;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
} catch (error) {
|
|
326
|
+
// 出错 / 取消时才强停子进程;正常读完要等它自己退出(提前销毁流会让它收到 SIGPIPE,退出码非 0)
|
|
327
|
+
if (cp.kill) cp.kill();
|
|
328
|
+
if (cp.stream.destroy) cp.stream.destroy();
|
|
329
|
+
throw error;
|
|
330
|
+
}
|
|
331
|
+
// tar 的结束块之后 cp 还可能补零:读掉剩下的,子进程才能正常退出
|
|
332
|
+
if (cp.stream.resume) cp.stream.resume();
|
|
333
|
+
const result = await cp.done;
|
|
334
|
+
if (result && result.code !== 0 && !/no such|not exist|could not find|not found/i.test(String(result.stderr || ''))) {
|
|
335
|
+
throw new Error(`读取容器内 ${scope} 失败: ${String(result.stderr || '').trim().slice(0, 200)}`);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function eventFilesFor(webHistoryDir, containerName) {
|
|
340
|
+
const key = encodeURIComponent(containerName);
|
|
341
|
+
const out = [];
|
|
342
|
+
for (const sub of ['events', 'projections']) {
|
|
343
|
+
const dir = path.join(webHistoryDir, sub);
|
|
344
|
+
let names = [];
|
|
345
|
+
try { names = fs.readdirSync(dir); } catch (error) { continue; }
|
|
346
|
+
for (const file of names) {
|
|
347
|
+
const base = file.replace(/\.jsonl?$/, '');
|
|
348
|
+
if (base === key || base.startsWith(`${key}${encodeURIComponent('~')}`)) out.push({ sub, file, full: path.join(dir, file) });
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return out;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* 主流程。
|
|
356
|
+
* options:
|
|
357
|
+
* out 最终字节的 Writable
|
|
358
|
+
* containers [{ name }]
|
|
359
|
+
* items 内容项 id 数组(plan.normalizeItems 的结果)
|
|
360
|
+
* includeSecrets 是否带密钥
|
|
361
|
+
* compression 'fast' | 'small'
|
|
362
|
+
* deps { homeDir, webHistoryDir, runtime, containerState, version, loadHistory(name) }
|
|
363
|
+
* hooks { check(), onBytes(n), onStage(info), onPause(name), onUnpause(name) }
|
|
364
|
+
* 返回 { entries, bytes, stats, ext }
|
|
365
|
+
*/
|
|
366
|
+
async function exportArchive(options) {
|
|
367
|
+
const { out, containers, items, includeSecrets, deps } = options;
|
|
368
|
+
const hooks = options.hooks || {};
|
|
369
|
+
const enabled = new Set(items);
|
|
370
|
+
const compressor = createCompressor(options.compression);
|
|
371
|
+
const tarSink = new PassThrough();
|
|
372
|
+
const finished = new Promise((resolve, reject) => {
|
|
373
|
+
pipeline(tarSink, compressor.stream, out, error => (error ? reject(error) : resolve()));
|
|
374
|
+
});
|
|
375
|
+
// 输出先于主流程出错(客户端断开)时不能变成未处理的拒绝:错误由主流程在 check / 写入处发现
|
|
376
|
+
finished.catch(() => {});
|
|
377
|
+
const writer = new TarWriter(tarSink, hooks);
|
|
378
|
+
const stats = {};
|
|
379
|
+
const paused = [];
|
|
380
|
+
|
|
381
|
+
const run = async () => {
|
|
382
|
+
const manifestContainers = [];
|
|
383
|
+
const workdirs = new Map();
|
|
384
|
+
const prepared = [];
|
|
385
|
+
for (let idx = 0; idx < containers.length; idx += 1) {
|
|
386
|
+
const name = containers[idx].name;
|
|
387
|
+
const info = deps.runtime.inspect(name);
|
|
388
|
+
const history = deps.loadHistory(name);
|
|
389
|
+
const applied = history.applied || {};
|
|
390
|
+
const snapshot = history.runtimeSnapshot || null;
|
|
391
|
+
const hostPath = (snapshot && snapshot.hostPath) || applied.hostPath || '';
|
|
392
|
+
const containerPath = (snapshot && snapshot.containerPath) || applied.containerPath || '';
|
|
393
|
+
let workKey = '';
|
|
394
|
+
if (enabled.has('workdir') && hostPath) {
|
|
395
|
+
try { workKey = fs.realpathSync(hostPath); } catch (error) { workKey = ''; }
|
|
396
|
+
}
|
|
397
|
+
if (workKey && !workdirs.has(workKey)) workdirs.set(workKey, workdirs.size);
|
|
398
|
+
prepared.push({ idx, name, info, history, hostPath, containerPath, workKey });
|
|
399
|
+
manifestContainers.push({
|
|
400
|
+
idx,
|
|
401
|
+
name,
|
|
402
|
+
remark: history.remark || '',
|
|
403
|
+
image: info ? info.image : (applied.imageName ? `${applied.imageName}:${applied.imageVersion || ''}` : ''),
|
|
404
|
+
containerPath,
|
|
405
|
+
hostPath,
|
|
406
|
+
fidelity: snapshot ? 'full' : 'partial',
|
|
407
|
+
work: workKey ? workdirs.get(workKey) : null,
|
|
408
|
+
exists: Boolean(info),
|
|
409
|
+
// 导入时要让用户确认的高风险设置
|
|
410
|
+
risks: summarizeRisks(snapshot, info && info.id && deps.containerState.isValidId(info.id) && enabled.has('settings')
|
|
411
|
+
? (readSmallFileSafe(deps.containerState.paths(deps.homeDir, info.id).autostart, 256 * 1024) || Buffer.alloc(0)).toString('utf8')
|
|
412
|
+
: '')
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
const manifest = {
|
|
416
|
+
format: plan.FORMAT,
|
|
417
|
+
manyoyoVersion: deps.version || '',
|
|
418
|
+
createdAt: new Date().toISOString(),
|
|
419
|
+
items: [...enabled],
|
|
420
|
+
tier: plan.matchTier(items),
|
|
421
|
+
includeSecrets: Boolean(includeSecrets),
|
|
422
|
+
containers: manifestContainers,
|
|
423
|
+
workdirs: [...workdirs.entries()].map(([hostReal, n]) => ({ n, hostPath: hostReal }))
|
|
424
|
+
};
|
|
425
|
+
await writer.addBuffer('manifest.json', `${JSON.stringify(manifest, null, 2)}\n`);
|
|
426
|
+
|
|
427
|
+
const workDone = new Set();
|
|
428
|
+
for (const item of prepared) {
|
|
429
|
+
hooks.check && hooks.check();
|
|
430
|
+
hooks.onStage && hooks.onStage({ stage: '准备', container: item.name });
|
|
431
|
+
const prefix = `c/${item.idx}`;
|
|
432
|
+
let pausedHere = false;
|
|
433
|
+
try {
|
|
434
|
+
// 运行中才暂停:冻结容器里所有进程,sqlite 一致,宿主机读工作目录时容器也换不了目录
|
|
435
|
+
if (item.info && item.info.status === 'running') {
|
|
436
|
+
// 暂停不可用(比如 cgroup 不支持)就退回“停止 → 导出 → 启动”
|
|
437
|
+
let how = 'pause';
|
|
438
|
+
hooks.onPause && hooks.onPause(item.name, item.info, how);
|
|
439
|
+
try {
|
|
440
|
+
deps.runtime.pause(item.name);
|
|
441
|
+
} catch (error) {
|
|
442
|
+
if (typeof deps.runtime.stop !== 'function') throw error;
|
|
443
|
+
how = 'stop';
|
|
444
|
+
hooks.onPause && hooks.onPause(item.name, item.info, how);
|
|
445
|
+
deps.runtime.stop(item.name);
|
|
446
|
+
}
|
|
447
|
+
pausedHere = how;
|
|
448
|
+
paused.push({ name: item.name, how });
|
|
449
|
+
}
|
|
450
|
+
await exportOneContainer({ writer, item, prefix, enabled, includeSecrets, deps, hooks, stats });
|
|
451
|
+
if (item.workKey && !workDone.has(item.workKey)) {
|
|
452
|
+
workDone.add(item.workKey);
|
|
453
|
+
hooks.onStage && hooks.onStage({ stage: '工作目录', container: item.name });
|
|
454
|
+
await addHostTree(writer, item.workKey, `w/${workdirs.get(item.workKey)}`, stats, hooks);
|
|
455
|
+
}
|
|
456
|
+
} finally {
|
|
457
|
+
if (pausedHere) {
|
|
458
|
+
try {
|
|
459
|
+
if (pausedHere === 'stop') deps.runtime.start(item.name);
|
|
460
|
+
else deps.runtime.unpause(item.name);
|
|
461
|
+
} catch (error) { /* 恢复失败由调用方的任务记录兜底 */ }
|
|
462
|
+
paused.splice(paused.findIndex(entry => entry.name === item.name), 1);
|
|
463
|
+
hooks.onUnpause && hooks.onUnpause(item.name);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
await writer.addBuffer('END.json', `${JSON.stringify({ entries: writer.entries + 1, bytes: writer.bytes, stats })}\n`);
|
|
468
|
+
await writer.finish();
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
try {
|
|
472
|
+
await run();
|
|
473
|
+
tarSink.end();
|
|
474
|
+
await finished;
|
|
475
|
+
} catch (error) {
|
|
476
|
+
tarSink.destroy(error);
|
|
477
|
+
await finished.catch(() => {});
|
|
478
|
+
throw error;
|
|
479
|
+
} finally {
|
|
480
|
+
// 异常路径上还没恢复的暂停容器
|
|
481
|
+
for (const entry of paused.splice(0)) {
|
|
482
|
+
try {
|
|
483
|
+
if (entry.how === 'stop') deps.runtime.start(entry.name);
|
|
484
|
+
else deps.runtime.unpause(entry.name);
|
|
485
|
+
} catch (error) { /* ignore */ }
|
|
486
|
+
hooks.onUnpause && hooks.onUnpause(entry.name);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
return { entries: writer.entries, bytes: writer.bytes, stats, ext: compressor.ext };
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
async function exportOneContainer({ writer, item, prefix, enabled, includeSecrets, deps, hooks, stats }) {
|
|
493
|
+
const { name, history, info } = item;
|
|
494
|
+
// 1. 网页聊天记录
|
|
495
|
+
if (enabled.has('history')) {
|
|
496
|
+
hooks.onStage && hooks.onStage({ stage: '聊天记录', container: name });
|
|
497
|
+
const data = includeSecrets ? history : redactHistory(history);
|
|
498
|
+
await writer.addBuffer(`${prefix}/history.json`, `${JSON.stringify(data)}\n`);
|
|
499
|
+
for (const file of eventFilesFor(deps.webHistoryDir, name)) {
|
|
500
|
+
await addHostFile(writer, file.full, `${prefix}/${file.sub}/${file.file}`);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
const id = info ? info.id : '';
|
|
504
|
+
const stateRoot = id && deps.containerState.isValidId(id) ? deps.containerState.paths(deps.homeDir, id) : null;
|
|
505
|
+
// 2. 容器设置
|
|
506
|
+
if (enabled.has('settings') && stateRoot) {
|
|
507
|
+
hooks.onStage && hooks.onStage({ stage: '容器设置', container: name });
|
|
508
|
+
const env = readSmallFileSafe(stateRoot.env, 256 * 1024);
|
|
509
|
+
if (env) await writer.addBuffer(`${prefix}/state/env`, includeSecrets ? env : redactEnvText(env.toString('utf8')));
|
|
510
|
+
for (const [file, label] of [[stateRoot.autostart, 'autostart.sh'], [stateRoot.systemPrompt, 'system-prompt.json'], [stateRoot.network, 'network.json'], [stateRoot.envFiles, 'env-files.json']]) {
|
|
511
|
+
const body = readSmallFileSafe(file, 1024 * 1024);
|
|
512
|
+
if (body) await writer.addBuffer(`${prefix}/state/${label}`, includeSecrets || label !== 'autostart.sh' ? body : plan.redactCommandText(body.toString('utf8')));
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
// 3. 图片与回退快照(容器可写目录,按不可信输入遍历)
|
|
516
|
+
if (stateRoot && enabled.has('uploads')) {
|
|
517
|
+
hooks.onStage && hooks.onStage({ stage: '聊天图片', container: name });
|
|
518
|
+
await addHostTree(writer, path.join(stateRoot.box, 'uploads'), `${prefix}/box/uploads`, stats, hooks);
|
|
519
|
+
}
|
|
520
|
+
if (stateRoot && enabled.has('snapshots')) {
|
|
521
|
+
hooks.onStage && hooks.onStage({ stage: '回退快照', container: name });
|
|
522
|
+
await addHostTree(writer, path.join(stateRoot.box, 'snap.git'), `${prefix}/box/snap.git`, stats, hooks);
|
|
523
|
+
}
|
|
524
|
+
// 4. 容器里的改动
|
|
525
|
+
const layerEnabled = ['agentSessions', 'agentExtras', 'systemLayer'].filter(id2 => enabled.has(id2));
|
|
526
|
+
if (info && layerEnabled.length) {
|
|
527
|
+
hooks.onStage && hooks.onStage({ stage: '容器内数据', container: name });
|
|
528
|
+
const diff = plan.parseDiff(deps.runtime.diff(name));
|
|
529
|
+
const mounts = info.mounts || [];
|
|
530
|
+
const enabledSet = new Set(layerEnabled);
|
|
531
|
+
const keep = (abs) => {
|
|
532
|
+
const cls = plan.classifyLayerPath(abs, mounts);
|
|
533
|
+
// 不带密钥时,各 Agent 保存登录凭据的文件也不能带
|
|
534
|
+
if (!includeSecrets && plan.isCredentialFile(abs)) {
|
|
535
|
+
stats.skippedCredentialFiles = (stats.skippedCredentialFiles || 0) + 1;
|
|
536
|
+
return false;
|
|
537
|
+
}
|
|
538
|
+
return cls !== null && enabledSet.has(cls) && diff.added.has(abs);
|
|
539
|
+
};
|
|
540
|
+
for (const scope of plan.layerScopes(enabledSet, [...diff.added.keys()])) {
|
|
541
|
+
if (plan.classifyLayerPath(scope, mounts) === null) continue;
|
|
542
|
+
await addContainerScope({ writer, runtime: deps.runtime, name, scope, prefix: `${prefix}/layer`, keep, stats, hooks });
|
|
543
|
+
}
|
|
544
|
+
if (enabledSet.has('systemLayer')) {
|
|
545
|
+
const deleted = diff.deleted.filter(p => plan.classifyLayerPath(p, mounts) === 'systemLayer');
|
|
546
|
+
await writer.addBuffer(`${prefix}/layer-deleted.json`, `${JSON.stringify(deleted)}\n`);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
module.exports = {
|
|
552
|
+
ExportCancelled,
|
|
553
|
+
TarWriter,
|
|
554
|
+
exportArchive,
|
|
555
|
+
createCompressor,
|
|
556
|
+
archiveFileName,
|
|
557
|
+
zstdAvailable,
|
|
558
|
+
addHostTree,
|
|
559
|
+
addHostFile,
|
|
560
|
+
readSmallFileSafe,
|
|
561
|
+
redactHistory,
|
|
562
|
+
redactEnvText,
|
|
563
|
+
eventFilesFor
|
|
564
|
+
};
|