@xcanwin/manyoyo 8.7.3 → 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 +87 -86
- package/lib/web/server.js +216 -7
- package/lib/web/transfer-routes.js +574 -0
- package/package.json +1 -1
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// 最小 tar 读写器(ustar + pax)。只认普通文件 / 目录 / 符号链接,硬链接和其余类型由读端标出来、交给调用方拒绝。
|
|
4
|
+
// 不引入第三方依赖:宿主机侧解包的安全规则要完全受控。
|
|
5
|
+
|
|
6
|
+
const BLOCK = 512;
|
|
7
|
+
const MAX_OCTAL_SIZE = 0o77777777777; // ustar size 字段能表示的上限(8GiB - 1),更大的走 pax size
|
|
8
|
+
const MAX_PAX_BYTES = 1024 * 1024;
|
|
9
|
+
|
|
10
|
+
const TYPES = {
|
|
11
|
+
'0': 'file',
|
|
12
|
+
'\0': 'file',
|
|
13
|
+
'5': 'dir',
|
|
14
|
+
'2': 'symlink',
|
|
15
|
+
'1': 'hardlink'
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
class TarError extends Error {
|
|
19
|
+
constructor(message) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.name = 'TarError';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function writeString(buf, value, offset, length) {
|
|
26
|
+
const bytes = Buffer.from(value, 'utf8');
|
|
27
|
+
bytes.copy(buf, offset, 0, Math.min(bytes.length, length));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function writeOctal(buf, value, offset, length) {
|
|
31
|
+
const text = Math.max(0, Math.floor(value)).toString(8).padStart(length - 1, '0');
|
|
32
|
+
buf.write(text.slice(-(length - 1)), offset, length - 1, 'ascii');
|
|
33
|
+
buf[offset + length - 1] = 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isPlainAscii(text) {
|
|
37
|
+
return /^[\x20-\x7e]*$/.test(text);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function pad(size) {
|
|
41
|
+
const rest = size % BLOCK;
|
|
42
|
+
return rest === 0 ? 0 : BLOCK - rest;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function paxRecord(key, value) {
|
|
46
|
+
const body = ` ${key}=${value}\n`;
|
|
47
|
+
let length = Buffer.byteLength(body);
|
|
48
|
+
// 长度字段本身也算在内
|
|
49
|
+
let total = length + String(length).length;
|
|
50
|
+
if (String(total).length !== String(length).length) total += 1;
|
|
51
|
+
return Buffer.from(`${total}${body}`, 'utf8');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function buildBlock({ name, mode = 0o644, size = 0, mtime = 0, type = '0', linkname = '', uid = 0, gid = 0 }) {
|
|
55
|
+
const block = Buffer.alloc(BLOCK);
|
|
56
|
+
writeString(block, name, 0, 100);
|
|
57
|
+
writeOctal(block, mode & 0o7777, 100, 8);
|
|
58
|
+
writeOctal(block, uid, 108, 8);
|
|
59
|
+
writeOctal(block, gid, 116, 8);
|
|
60
|
+
writeOctal(block, Math.min(size, MAX_OCTAL_SIZE), 124, 12);
|
|
61
|
+
writeOctal(block, mtime, 136, 12);
|
|
62
|
+
block.fill(0x20, 148, 156);
|
|
63
|
+
block.write(type, 156, 1, 'ascii');
|
|
64
|
+
writeString(block, linkname, 157, 100);
|
|
65
|
+
block.write('ustar\0', 257, 6, 'latin1');
|
|
66
|
+
block.write('00', 263, 2, 'ascii');
|
|
67
|
+
let sum = 0;
|
|
68
|
+
for (let i = 0; i < BLOCK; i++) sum += block[i];
|
|
69
|
+
const text = sum.toString(8).padStart(6, '0');
|
|
70
|
+
block.write(text, 148, 6, 'ascii');
|
|
71
|
+
block[154] = 0;
|
|
72
|
+
block[155] = 0x20;
|
|
73
|
+
return block;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 一个条目的头部(必要时前面带 pax 扩展头)。meta:{ name, type: 'file'|'dir'|'symlink', size, mode, mtime, linkname }
|
|
78
|
+
* 名字超过 100 字节、含非 ASCII、size 超过 8GiB-1 都走 pax。
|
|
79
|
+
*/
|
|
80
|
+
function encodeEntryHeader(meta) {
|
|
81
|
+
const type = meta.type === 'dir' ? '5' : meta.type === 'symlink' ? '2' : '0';
|
|
82
|
+
const name = meta.type === 'dir' && !meta.name.endsWith('/') ? `${meta.name}/` : meta.name;
|
|
83
|
+
const size = meta.type === 'file' ? Number(meta.size || 0) : 0;
|
|
84
|
+
const linkname = meta.linkname || '';
|
|
85
|
+
const records = [];
|
|
86
|
+
if (Buffer.byteLength(name) > 100 || !isPlainAscii(name)) records.push(paxRecord('path', name));
|
|
87
|
+
if (Buffer.byteLength(linkname) > 100 || !isPlainAscii(linkname)) records.push(paxRecord('linkpath', linkname));
|
|
88
|
+
if (size > MAX_OCTAL_SIZE) records.push(paxRecord('size', String(size)));
|
|
89
|
+
const parts = [];
|
|
90
|
+
if (records.length) {
|
|
91
|
+
const body = Buffer.concat(records);
|
|
92
|
+
parts.push(buildBlock({ name: 'PaxHeader', type: 'x', size: body.length, mode: 0o644 }));
|
|
93
|
+
parts.push(body, Buffer.alloc(pad(body.length)));
|
|
94
|
+
}
|
|
95
|
+
const plainName = Buffer.byteLength(name) > 100 || !isPlainAscii(name) ? 'long-name' : name;
|
|
96
|
+
const plainLink = Buffer.byteLength(linkname) > 100 || !isPlainAscii(linkname) ? 'long-link' : linkname;
|
|
97
|
+
parts.push(buildBlock({
|
|
98
|
+
name: plainName,
|
|
99
|
+
mode: meta.mode === undefined ? (type === '5' ? 0o755 : 0o644) : meta.mode,
|
|
100
|
+
size,
|
|
101
|
+
mtime: Math.floor(meta.mtime || 0),
|
|
102
|
+
type,
|
|
103
|
+
linkname: plainLink,
|
|
104
|
+
uid: meta.uid || 0,
|
|
105
|
+
gid: meta.gid || 0
|
|
106
|
+
}));
|
|
107
|
+
return Buffer.concat(parts);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const END_BLOCKS = Buffer.alloc(BLOCK * 2);
|
|
111
|
+
|
|
112
|
+
function readString(block, offset, length) {
|
|
113
|
+
let end = offset;
|
|
114
|
+
const limit = offset + length;
|
|
115
|
+
while (end < limit && block[end] !== 0) end++;
|
|
116
|
+
return block.toString('utf8', offset, end);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function readNumber(block, offset, length) {
|
|
120
|
+
// base-256(GNU 对超大数的编码):最高位为 1
|
|
121
|
+
if (block[offset] & 0x80) {
|
|
122
|
+
let value = block[offset] & 0x7f;
|
|
123
|
+
for (let i = 1; i < length; i++) value = value * 256 + block[offset + i];
|
|
124
|
+
return value;
|
|
125
|
+
}
|
|
126
|
+
const text = readString(block, offset, length).trim();
|
|
127
|
+
if (text === '') return 0;
|
|
128
|
+
if (!/^[0-7]+$/.test(text)) throw new TarError('tar 头部数字字段非法');
|
|
129
|
+
return parseInt(text, 8);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function isZeroBlock(block) {
|
|
133
|
+
for (let i = 0; i < BLOCK; i++) if (block[i] !== 0) return false;
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function verifyChecksum(block) {
|
|
138
|
+
const expected = readNumber(block, 148, 8);
|
|
139
|
+
let sum = 0;
|
|
140
|
+
for (let i = 0; i < BLOCK; i++) sum += (i >= 148 && i < 156) ? 0x20 : block[i];
|
|
141
|
+
if (sum !== expected) throw new TarError('tar 头部校验和不符');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function parsePax(body) {
|
|
145
|
+
const out = {};
|
|
146
|
+
let pos = 0;
|
|
147
|
+
while (pos < body.length) {
|
|
148
|
+
const space = body.indexOf(0x20, pos);
|
|
149
|
+
if (space < 0) throw new TarError('pax 记录非法');
|
|
150
|
+
const length = parseInt(body.toString('ascii', pos, space), 10);
|
|
151
|
+
if (!Number.isInteger(length) || length <= 0 || pos + length > body.length) throw new TarError('pax 记录长度非法');
|
|
152
|
+
const record = body.toString('utf8', space + 1, pos + length - 1);
|
|
153
|
+
const eq = record.indexOf('=');
|
|
154
|
+
if (eq > 0) out[record.slice(0, eq)] = record.slice(eq + 1);
|
|
155
|
+
pos += length;
|
|
156
|
+
}
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function metaFromBlock(block, pax) {
|
|
161
|
+
const prefix = readString(block, 345, 155);
|
|
162
|
+
let name = readString(block, 0, 100);
|
|
163
|
+
if (prefix && block.toString('ascii', 257, 262) === 'ustar') name = `${prefix}/${name}`;
|
|
164
|
+
const typeflag = String.fromCharCode(block[156]);
|
|
165
|
+
const meta = {
|
|
166
|
+
name,
|
|
167
|
+
type: TYPES[typeflag] || 'other',
|
|
168
|
+
typeflag,
|
|
169
|
+
size: readNumber(block, 124, 12),
|
|
170
|
+
mode: readNumber(block, 100, 8),
|
|
171
|
+
mtime: readNumber(block, 136, 12),
|
|
172
|
+
linkname: readString(block, 157, 100)
|
|
173
|
+
};
|
|
174
|
+
if (pax.path !== undefined) meta.name = pax.path;
|
|
175
|
+
if (pax.linkpath !== undefined) meta.linkname = pax.linkpath;
|
|
176
|
+
if (pax.size !== undefined) {
|
|
177
|
+
const size = Number(pax.size);
|
|
178
|
+
if (!Number.isSafeInteger(size) || size < 0) throw new TarError('pax size 非法');
|
|
179
|
+
meta.size = size;
|
|
180
|
+
}
|
|
181
|
+
if (meta.type === 'dir' && meta.name.endsWith('/')) meta.name = meta.name.slice(0, -1);
|
|
182
|
+
return meta;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* 只解析内存里的头部(可含 pax 扩展头,不含正文):用于测试和快速探测。
|
|
187
|
+
* 返回 { meta, consumed },meta 为 null 表示读到了结束块。
|
|
188
|
+
*/
|
|
189
|
+
function decodeEntryHeader(buffer) {
|
|
190
|
+
let pos = 0;
|
|
191
|
+
let pax = {};
|
|
192
|
+
for (;;) {
|
|
193
|
+
if (buffer.length - pos < BLOCK) throw new TarError('tar 头部不完整');
|
|
194
|
+
const block = buffer.subarray(pos, pos + BLOCK);
|
|
195
|
+
if (isZeroBlock(block)) return { meta: null, consumed: pos + BLOCK };
|
|
196
|
+
verifyChecksum(block);
|
|
197
|
+
const typeflag = String.fromCharCode(block[156]);
|
|
198
|
+
if (typeflag === 'x' || typeflag === 'g') {
|
|
199
|
+
const size = readNumber(block, 124, 12);
|
|
200
|
+
if (size > MAX_PAX_BYTES) throw new TarError('pax 扩展头过大');
|
|
201
|
+
const bodyEnd = pos + BLOCK + size;
|
|
202
|
+
if (buffer.length < bodyEnd) throw new TarError('tar 头部不完整');
|
|
203
|
+
if (typeflag === 'x') pax = { ...pax, ...parsePax(buffer.subarray(pos + BLOCK, bodyEnd)) };
|
|
204
|
+
pos = bodyEnd + pad(size);
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
return { meta: metaFromBlock(block, pax), consumed: pos + BLOCK };
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// 按需从异步字节源里取定长数据
|
|
212
|
+
class ByteReader {
|
|
213
|
+
constructor(source) {
|
|
214
|
+
this.iterator = source[Symbol.asyncIterator]();
|
|
215
|
+
this.buffer = Buffer.alloc(0);
|
|
216
|
+
this.done = false;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async fill() {
|
|
220
|
+
if (this.done) return false;
|
|
221
|
+
const { value, done } = await this.iterator.next();
|
|
222
|
+
if (done) {
|
|
223
|
+
this.done = true;
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value);
|
|
227
|
+
this.buffer = this.buffer.length ? Buffer.concat([this.buffer, chunk]) : chunk;
|
|
228
|
+
return true;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// 恰好 n 字节;一个字节都没有就到了流尾返回 null;中途断了抛截断错误
|
|
232
|
+
async readExact(n) {
|
|
233
|
+
while (this.buffer.length < n) {
|
|
234
|
+
if (!(await this.fill())) {
|
|
235
|
+
if (this.buffer.length === 0) return null;
|
|
236
|
+
throw new TarError('tar 流被截断');
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
const out = this.buffer.subarray(0, n);
|
|
240
|
+
this.buffer = this.buffer.subarray(n);
|
|
241
|
+
return out;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// 分块取 n 字节(不一次性放进内存)
|
|
245
|
+
async *chunks(n) {
|
|
246
|
+
let left = n;
|
|
247
|
+
while (left > 0) {
|
|
248
|
+
if (this.buffer.length === 0 && !(await this.fill())) throw new TarError('tar 流被截断');
|
|
249
|
+
const take = Math.min(left, this.buffer.length);
|
|
250
|
+
const piece = this.buffer.subarray(0, take);
|
|
251
|
+
this.buffer = this.buffer.subarray(take);
|
|
252
|
+
left -= take;
|
|
253
|
+
yield piece;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// 读掉剩下的所有字节(tar 结束块之后上游可能还有补零;不读掉,上游进程会堵在写管道上不退出)
|
|
258
|
+
async drain() {
|
|
259
|
+
try {
|
|
260
|
+
while (await this.fill()) this.buffer = Buffer.alloc(0);
|
|
261
|
+
} catch (error) {
|
|
262
|
+
// 结束块之后的错误不影响已经读到的内容
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async skip(n) {
|
|
267
|
+
for await (const piece of this.chunks(n)) void piece;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* 流式读端(拉取式,天然带背压):
|
|
273
|
+
* for await (const { meta, body } of readTarEntries(stream)) { for await (const chunk of body) ... }
|
|
274
|
+
* body 是该条目正文的异步迭代器(目录 / 链接为空);没读完的部分在进入下一个条目前自动跳过。
|
|
275
|
+
* meta.type 为 'other' / 'hardlink' 由调用方决定怎么处理(导入一律拒绝)。
|
|
276
|
+
* 流在条目中途结束会抛 TarError('tar 流被截断');在条目边界干净结束视为正常。
|
|
277
|
+
*/
|
|
278
|
+
async function* readTarEntries(source, options = {}) {
|
|
279
|
+
const maxEntrySize = options.maxEntrySize || Infinity;
|
|
280
|
+
const reader = new ByteReader(source);
|
|
281
|
+
let pax = {};
|
|
282
|
+
for (;;) {
|
|
283
|
+
const block = await reader.readExact(BLOCK);
|
|
284
|
+
if (block === null) return;
|
|
285
|
+
if (isZeroBlock(block)) {
|
|
286
|
+
await reader.drain();
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
verifyChecksum(block);
|
|
290
|
+
const typeflag = String.fromCharCode(block[156]);
|
|
291
|
+
if (typeflag === 'x' || typeflag === 'g') {
|
|
292
|
+
const size = readNumber(block, 124, 12);
|
|
293
|
+
if (size > MAX_PAX_BYTES) throw new TarError('pax 扩展头过大');
|
|
294
|
+
const parts = [];
|
|
295
|
+
for await (const piece of reader.chunks(size)) parts.push(piece);
|
|
296
|
+
await reader.skip(pad(size));
|
|
297
|
+
if (typeflag === 'x') pax = { ...pax, ...parsePax(Buffer.concat(parts)) };
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
const meta = metaFromBlock(block, pax);
|
|
301
|
+
pax = {};
|
|
302
|
+
if (meta.size > maxEntrySize) throw new TarError(`条目过大: ${meta.name}`);
|
|
303
|
+
const size = meta.type === 'file' ? meta.size : 0;
|
|
304
|
+
let consumed = 0;
|
|
305
|
+
const body = (async function* () {
|
|
306
|
+
for await (const piece of reader.chunks(size)) {
|
|
307
|
+
consumed += piece.length;
|
|
308
|
+
yield piece;
|
|
309
|
+
}
|
|
310
|
+
})();
|
|
311
|
+
yield { meta, body };
|
|
312
|
+
// 调用方没读完:丢弃剩余正文
|
|
313
|
+
if (consumed < size) {
|
|
314
|
+
await reader.skip(size - consumed);
|
|
315
|
+
}
|
|
316
|
+
await reader.skip(pad(size));
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
module.exports = {
|
|
321
|
+
BLOCK,
|
|
322
|
+
MAX_OCTAL_SIZE,
|
|
323
|
+
END_BLOCKS,
|
|
324
|
+
TarError,
|
|
325
|
+
readTarEntries,
|
|
326
|
+
encodeEntryHeader,
|
|
327
|
+
decodeEntryHeader,
|
|
328
|
+
pad
|
|
329
|
+
};
|
package/lib/model-services.js
CHANGED
|
@@ -19,7 +19,7 @@ function agentLabelOf(agent) {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
const RUN_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,39}$/;
|
|
22
|
-
// 订阅登录用的长期令牌;保存它时要删掉同一个运行配置里会盖过订阅的 API Key 类变量(Claude Code 同时有 Key 时优先用 Key)
|
|
22
|
+
// 订阅登录用的长期令牌;保存它时要删掉同一个运行配置里会盖过订阅的 API Key 类变量(Base URL 不删)(Claude Code 同时有 Key 时优先用 Key)
|
|
23
23
|
const CLAUDE_OAUTH_KEY = 'CLAUDE_CODE_OAUTH_TOKEN';
|
|
24
24
|
const CLAUDE_API_KEYS = ['ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY'];
|
|
25
25
|
|
|
@@ -170,6 +170,12 @@ function buildNewService(config, input, options = {}) {
|
|
|
170
170
|
if (input.subscription) {
|
|
171
171
|
if (agent !== 'claude') throw new Error('订阅登录令牌目前只支持 Claude Code');
|
|
172
172
|
env[CLAUDE_OAUTH_KEY] = secret;
|
|
173
|
+
// 只有转发订阅令牌的网关才需要 Base URL;模型同理,都是选填
|
|
174
|
+
const baseUrl = String(input.baseUrl || '').trim();
|
|
175
|
+
if (baseUrl && !/^https?:\/\//i.test(baseUrl)) throw new Error('Base URL 必须以 http:// 或 https:// 开头');
|
|
176
|
+
if (baseUrl) env[spec.baseUrlKey] = baseUrl;
|
|
177
|
+
const model = String(input.model || '').trim();
|
|
178
|
+
if (model) env[spec.modelKey] = model;
|
|
173
179
|
baseName = `${agent}-subscription`;
|
|
174
180
|
} else if (input.providerId) {
|
|
175
181
|
const preset = presetFor(agent, String(input.providerId));
|
|
@@ -238,7 +244,7 @@ function buildReuseService(config, fromName, targetAgent, options = {}) {
|
|
|
238
244
|
|
|
239
245
|
/**
|
|
240
246
|
* 编辑已有服务:key 留空表示不改。返回配置改动列表 ops(set / remove,路径从根开始)。
|
|
241
|
-
* 订阅令牌:换成订阅令牌时删掉同一个运行配置里的 API Key
|
|
247
|
+
* 订阅令牌:换成订阅令牌时删掉同一个运行配置里的 API Key 类变量(Base URL 保留,订阅令牌也能走转发网关)。
|
|
242
248
|
*/
|
|
243
249
|
function buildServiceEdit(config, name, patch) {
|
|
244
250
|
const runs = isPlainObject(config && config.runs) ? config.runs : {};
|
|
@@ -261,7 +267,7 @@ function buildServiceEdit(config, name, patch) {
|
|
|
261
267
|
if (patch.subscription) {
|
|
262
268
|
if (agent !== 'claude') throw new Error('订阅登录令牌目前只支持 Claude Code');
|
|
263
269
|
setEnv(CLAUDE_OAUTH_KEY, secret);
|
|
264
|
-
|
|
270
|
+
CLAUDE_API_KEYS.forEach(key => {
|
|
265
271
|
if (isPlainObject(run.env) && key in run.env) ops.push({ type: 'remove', path: ['runs', name, 'env', key] });
|
|
266
272
|
});
|
|
267
273
|
} else {
|
package/lib/setup.js
CHANGED
|
@@ -93,9 +93,9 @@ function buildAgentRunProfile(agent, envInput, options = {}) {
|
|
|
93
93
|
const existingEnv = isPlainObject(existing.env) ? existing.env : {};
|
|
94
94
|
const profile = { ...existing };
|
|
95
95
|
profile.env = { ...existingEnv, ...provided };
|
|
96
|
-
// 换成订阅令牌时,同一个运行配置里的 API Key
|
|
96
|
+
// 换成订阅令牌时,同一个运行配置里的 API Key 类变量要删掉:Claude Code 同时有 Key 时优先用 Key,订阅就被绕过了
|
|
97
97
|
if (agent === 'claude' && provided.CLAUDE_CODE_OAUTH_TOKEN && !['ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY'].some(key => provided[key])) {
|
|
98
|
-
for (const key of ['ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY'
|
|
98
|
+
for (const key of ['ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY']) delete profile.env[key];
|
|
99
99
|
}
|
|
100
100
|
if (!hasNonEmptyRequiredKey(spec, profile.env)) {
|
|
101
101
|
throw new Error(`请填写 ${spec.requiredAnyOf.join(' 或 ')}`);
|
package/lib/web/AGENTS.md
CHANGED
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
- 请求处理链路上注册在**全局认证网关之前**的代码(如 `res.on('finish')` 之类的钩子)必须自带 try/catch:那里抛异常会冒泡成 `uncaughtException`,而 `bin/manyoyo.js` 的处理器直接 `process.exit(1)`——等于未认证请求可打挂 serve。回归用例见 `test/web-server-auth.test.js` 的 `Web Server Robustness`。
|
|
33
33
|
- **终端 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。
|
|
34
34
|
- 空闲保活是 `/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 侧空闲超时。
|
|
35
|
+
- **容器导出 / 导入接口**(`lib/web/transfer-routes.js`,核心在 `lib/archive/`,总说明见根 `AGENTS.md`):`GET /api/transfer/preview`、`POST /api/exports`(`output: download|server`)、`GET /api/exports/<id>/download`(一次性)、`GET/DELETE /api/exports/files/<文件名>`(白名单 `^manyoyo-export-\d{8}-\d{6}\.tar\.(zst|gz)$`)、`GET /api/jobs[/<id>]`、`POST /api/jobs/<id>/cancel`、`POST /api/imports/inspect`(只读文件开头 ≤4MB 的清单)、`POST /api/imports`(`?plan=` + 原始请求体流,或 JSON `{serverFile, plan}`;响应在全部处理完才回来,进度看会话摘要里的 `job`)。**闸门**:`handleWebApi` 一开头按路径归到容器(`/api/sessions|containers/<名>/…`,名字可能是 `容器~agent`),任务期间 409 `{busy, jobId}`,只放行 `GET messages|detail`;终端 WebSocket 升级处同样拒绝;新增“带容器名的路由”不需要改闸门,但**新增不在 `/api/sessions|containers/<名>/` 下、却会动某个容器的接口要自己判断 `state.jobs.busyFor(名)`**。AGENT 正在执行(`state.agentRuns`)时导出直接 409。导入 / 导出文件名、`plan` 里的名字和路径都要重新校验;导入目的地不能是 `/`、`/home`、`$HOME`、`~/.manyoyo` 里,且必须不存在或为空。
|
|
35
36
|
- 新增接口/页面必须走全局认证网关,禁止在业务路由里零散补认证;匿名白名单见根 `AGENTS.md` 的安全约束。
|
|
36
37
|
- **系统提示词**(`lib/agent-instructions.js` 唯一数据源,`buildWebAgentExecCommand` 的 `options.instructions`):全局配置(`agentInstructions` + 两个 `agentInstructions{Builtin,Custom}Enabled` 开关,`composeSystemPrompt`)只决定**新建容器时的初始内容**;创建时(请求 `systemPrompt:{enabled,text}`,没给就现算)存进容器状态目录 `system-prompt.json`,之后只能经 `/api/containers/:name/system-prompt` 改,改系统设置不影响已有容器。每次执行时读该容器的文件(`readContainerSystemPrompt`),旧容器没有文件就退回按当前全局配置现算;claude 用 `--append-system-prompt`、codex 用 `-c developer_instructions=<TOML 字符串>`、opencode 把说明写进容器 `/tmp/manyoyo-agent-instructions.md` 再经 `OPENCODE_CONFIG_CONTENT` 合并(1.19.3 实测与用户的 instructions 拼接);gemini 只有 `GEMINI_SYSTEM_MD` 这种整体替换,不注入。用户模板里已有同名参数 / 变量就不注入;用户追加的文本去控制字符、把 `{prompt}` 拆开(否则会被模板替换成提示词)。CLI 的 `manyoyo run` 是交互终端,不注入。
|
|
37
38
|
- **会话标题**:显示优先级 备注 > `title`(claude 的 `/rename` 写 `custom-title`、交互模式写 `ai-title`,**`-p` 下实测不生成 `ai-title`**)> `autoTitle`(轻量调用生成)> `firstPrompt` > AGENT N,服务端摘要与前端 `sessionDisplayTitle` 要同步改。每轮结束后 `refreshWebAgentTitle` 在后台只读会话 jsonl 尾部 256KB(session id 按 UUID 校验),失败静默不写日志;`firstPrompt` 在第一次追加 Agent 用户消息时记下(历史会被裁剪,不能现算)。**自动标题**(`maybeStartAutoTitle`):第一条 Agent 消息落盘后与第一轮并行发一次轻量调用(`agent-light-call.js`,claude `--no-session-persistence --tools ""` + `CLAUDE_CODE_EFFORT_LEVEL=low`,codex `exec --ephemeral`;实测各约 2 秒、一两百个输入 token),`autoTitleTried` 在调用**前**就落盘(失败也不重试,避免每次刷新都花钱),有备注 / `title` 时不生成;`ctx.autoTitle === false` 可关(那些按“最后一次 exec 就是 Agent 那一轮”断言的测试要关,否则轻量调用混进去)。这些字段与 `pinned`、`slashCommands`、`effort`、`contextUsage`、`recap`、`forkPending` 都要同时加进 `createEmptyWebAgentSession` 和 `normalizeWebAgentSessionRecord`,否则落盘丢失。
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// 容器导出 / 导入任务:登记、进度、取消、闸门,以及 serve 重启后的恢复。
|
|
4
|
+
// 任务记录落盘到 ~/.manyoyo/jobs/<id>.json:记下“被本任务暂停的容器”和“本任务新建的容器 / 目录 / 历史文件”,
|
|
5
|
+
// serve 被强杀后启动时据此恢复暂停、撤销没做完的导入。恢复时只动记录在案、且 manyoyo.id 对得上的容器(宁可删不掉)。
|
|
6
|
+
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const os = require('os');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const crypto = require('crypto');
|
|
11
|
+
const { writeConfigFileSecure } = require('../secure-file');
|
|
12
|
+
|
|
13
|
+
const JOB_ID_RE = /^[0-9a-f]{16}$/;
|
|
14
|
+
|
|
15
|
+
class JobCancelled extends Error {
|
|
16
|
+
constructor() {
|
|
17
|
+
super('任务已取消');
|
|
18
|
+
this.name = 'JobCancelled';
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isProcessAlive(pid) {
|
|
23
|
+
try {
|
|
24
|
+
process.kill(pid, 0);
|
|
25
|
+
return true;
|
|
26
|
+
} catch (error) {
|
|
27
|
+
return error.code === 'EPERM';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function jobsDir(homeDir) {
|
|
32
|
+
return path.join(homeDir, '.manyoyo', 'jobs');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function createJobRegistry(options = {}) {
|
|
36
|
+
const homeDir = options.homeDir || os.homedir();
|
|
37
|
+
const dir = jobsDir(homeDir);
|
|
38
|
+
const jobs = new Map();
|
|
39
|
+
const FINISHED_KEEP = 20;
|
|
40
|
+
|
|
41
|
+
function persist(job) {
|
|
42
|
+
if (job.status !== 'running') {
|
|
43
|
+
try { fs.unlinkSync(path.join(dir, `${job.id}.json`)); } catch (error) { /* 没落盘过 */ }
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
48
|
+
const data = { ...job.record() };
|
|
49
|
+
writeConfigFileSecure(path.join(dir, `${job.id}.json`), `${JSON.stringify(data)}\n`);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
// 落盘失败不影响任务本身,只是崩溃后无法自动恢复
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function makeJob({ type, containers, meta = {}, id: fixedId, status = 'running' }) {
|
|
56
|
+
const id = fixedId || crypto.randomBytes(8).toString('hex');
|
|
57
|
+
const job = {
|
|
58
|
+
id,
|
|
59
|
+
type,
|
|
60
|
+
containers: [...containers],
|
|
61
|
+
status,
|
|
62
|
+
stage: '',
|
|
63
|
+
container: '',
|
|
64
|
+
bytes: 0,
|
|
65
|
+
estimate: Number(meta.estimate) || 0,
|
|
66
|
+
startedAt: new Date().toISOString(),
|
|
67
|
+
finishedAt: null,
|
|
68
|
+
error: '',
|
|
69
|
+
result: null,
|
|
70
|
+
paused: [],
|
|
71
|
+
created: { containers: [], dirs: [], historyFiles: [] },
|
|
72
|
+
partFile: '',
|
|
73
|
+
cancelRequested: false,
|
|
74
|
+
meta,
|
|
75
|
+
// 给界面看的快照(不含内部路径)
|
|
76
|
+
view() {
|
|
77
|
+
return {
|
|
78
|
+
id: job.id,
|
|
79
|
+
type: job.type,
|
|
80
|
+
status: job.status,
|
|
81
|
+
containers: job.containers,
|
|
82
|
+
stage: job.stage,
|
|
83
|
+
container: job.container,
|
|
84
|
+
bytes: job.bytes,
|
|
85
|
+
estimate: job.estimate,
|
|
86
|
+
startedAt: job.startedAt,
|
|
87
|
+
finishedAt: job.finishedAt,
|
|
88
|
+
error: job.error,
|
|
89
|
+
result: job.result,
|
|
90
|
+
cancelRequested: job.cancelRequested
|
|
91
|
+
};
|
|
92
|
+
},
|
|
93
|
+
// 落盘的记录:恢复需要的内部信息
|
|
94
|
+
record() {
|
|
95
|
+
return {
|
|
96
|
+
id: job.id,
|
|
97
|
+
type: job.type,
|
|
98
|
+
pid: process.pid,
|
|
99
|
+
containers: job.containers,
|
|
100
|
+
startedAt: job.startedAt,
|
|
101
|
+
paused: job.paused,
|
|
102
|
+
created: job.created,
|
|
103
|
+
partFile: job.partFile
|
|
104
|
+
};
|
|
105
|
+
},
|
|
106
|
+
update(patch) {
|
|
107
|
+
Object.assign(job, patch);
|
|
108
|
+
persist(job);
|
|
109
|
+
},
|
|
110
|
+
check() {
|
|
111
|
+
if (job.cancelRequested) throw new JobCancelled();
|
|
112
|
+
},
|
|
113
|
+
addBytes(n) {
|
|
114
|
+
job.bytes += n;
|
|
115
|
+
},
|
|
116
|
+
addPaused(name, id2, how = 'pause') {
|
|
117
|
+
job.paused = job.paused.filter(item => item.name !== name);
|
|
118
|
+
job.paused.push({ name, id: id2, how });
|
|
119
|
+
persist(job);
|
|
120
|
+
},
|
|
121
|
+
removePaused(name) {
|
|
122
|
+
job.paused = job.paused.filter(item => item.name !== name);
|
|
123
|
+
persist(job);
|
|
124
|
+
},
|
|
125
|
+
addCreated(kind, value) {
|
|
126
|
+
job.created[kind].push(value);
|
|
127
|
+
persist(job);
|
|
128
|
+
},
|
|
129
|
+
finish(status, extra = {}) {
|
|
130
|
+
job.status = status;
|
|
131
|
+
job.finishedAt = new Date().toISOString();
|
|
132
|
+
Object.assign(job, extra);
|
|
133
|
+
persist(job);
|
|
134
|
+
// 只留最近几个已结束的任务
|
|
135
|
+
const finished = [...jobs.values()].filter(item => item.status !== 'running');
|
|
136
|
+
finished.slice(0, Math.max(0, finished.length - FINISHED_KEEP)).forEach(item => jobs.delete(item.id));
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
jobs.set(id, job);
|
|
140
|
+
persist(job);
|
|
141
|
+
return job;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
dir,
|
|
146
|
+
create: makeJob,
|
|
147
|
+
get: id => jobs.get(id) || null,
|
|
148
|
+
list: () => [...jobs.values()].map(job => job.view()),
|
|
149
|
+
// 正在跑的、涉及这个容器的任务
|
|
150
|
+
busyFor(containerName) {
|
|
151
|
+
for (const job of jobs.values()) {
|
|
152
|
+
if (job.status === 'running' && job.containers.includes(containerName)) return job;
|
|
153
|
+
}
|
|
154
|
+
return null;
|
|
155
|
+
},
|
|
156
|
+
running: () => [...jobs.values()].filter(job => job.status === 'running'),
|
|
157
|
+
hasRunning: () => [...jobs.values()].some(job => job.status === 'running'),
|
|
158
|
+
cancel(id) {
|
|
159
|
+
const job = jobs.get(id);
|
|
160
|
+
if (!job || job.status !== 'running') return false;
|
|
161
|
+
job.cancelRequested = true;
|
|
162
|
+
return true;
|
|
163
|
+
},
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* 启动时恢复。actions:
|
|
167
|
+
* containerIdOf(name) → 当前容器的 manyoyo.id(不存在返回 '')
|
|
168
|
+
* isPaused(name) → 容器当前是否处于暂停
|
|
169
|
+
* unpause(name)
|
|
170
|
+
* removeContainer(name)
|
|
171
|
+
* removePath(p) 删目录或文件
|
|
172
|
+
* 返回处理过的任务记录列表(status: 'interrupted')。
|
|
173
|
+
*/
|
|
174
|
+
recover(actions) {
|
|
175
|
+
const handled = [];
|
|
176
|
+
let names = [];
|
|
177
|
+
try { names = fs.readdirSync(dir); } catch (error) { return handled; }
|
|
178
|
+
for (const file of names) {
|
|
179
|
+
if (!/^[0-9a-f]{16}\.json$/.test(file)) continue;
|
|
180
|
+
const full = path.join(dir, file);
|
|
181
|
+
let record;
|
|
182
|
+
try {
|
|
183
|
+
record = JSON.parse(fs.readFileSync(full, 'utf8'));
|
|
184
|
+
} catch (error) {
|
|
185
|
+
try { fs.unlinkSync(full); } catch (e) { /* ignore */ }
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
if (!record || record.id !== file.slice(0, -5)) {
|
|
189
|
+
try { fs.unlinkSync(full); } catch (error) { /* ignore */ }
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
// 同一个 HOME 上另一个还活着的 serve 进程在跑的任务不是“上次崩了留下的”,别动
|
|
193
|
+
if (Number.isInteger(record.pid) && record.pid !== process.pid && isProcessAlive(record.pid)) continue;
|
|
194
|
+
const report = { id: record.id, type: record.type, containers: record.containers || [], restored: [], removed: [], skipped: [], failed: [] };
|
|
195
|
+
for (const item of Array.isArray(record.paused) ? record.paused : []) {
|
|
196
|
+
try {
|
|
197
|
+
if (item && item.id && item.how === 'stop' && actions.containerIdOf(item.name) === item.id && actions.isStopped && actions.isStopped(item.name)) {
|
|
198
|
+
actions.start(item.name);
|
|
199
|
+
report.restored.push(item.name);
|
|
200
|
+
} else if (item && item.id && item.how !== 'stop' && actions.containerIdOf(item.name) === item.id && actions.isPaused(item.name)) {
|
|
201
|
+
actions.unpause(item.name);
|
|
202
|
+
report.restored.push(item.name);
|
|
203
|
+
} else {
|
|
204
|
+
report.skipped.push(item && item.name);
|
|
205
|
+
}
|
|
206
|
+
} catch (error) {
|
|
207
|
+
report.failed.push(item && item.name);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
const created = record.created || {};
|
|
211
|
+
for (const item of Array.isArray(created.containers) ? created.containers : []) {
|
|
212
|
+
try {
|
|
213
|
+
if (item && item.id && actions.containerIdOf(item.name) === item.id) {
|
|
214
|
+
actions.removeContainer(item.name);
|
|
215
|
+
report.removed.push(item.name);
|
|
216
|
+
} else {
|
|
217
|
+
report.skipped.push(item && item.name);
|
|
218
|
+
}
|
|
219
|
+
} catch (error) {
|
|
220
|
+
report.failed.push(item && item.name);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
for (const kind of ['dirs', 'historyFiles']) {
|
|
224
|
+
for (const p of Array.isArray(created[kind]) ? created[kind] : []) {
|
|
225
|
+
try { actions.removePath(p); report.removed.push(p); } catch (error) { report.failed.push(p); }
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (record.partFile) {
|
|
229
|
+
try { actions.removePath(record.partFile); } catch (error) { report.failed.push(record.partFile); }
|
|
230
|
+
}
|
|
231
|
+
// 有动作没做成(运行时还没起来、删除失败)时保留记录,下次启动再试;全部处理完才删
|
|
232
|
+
if (report.failed.length) {
|
|
233
|
+
handled.push(report);
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
try { fs.unlinkSync(full); } catch (error) { /* ignore */ }
|
|
237
|
+
// 登记成“已中断”,界面能看到
|
|
238
|
+
const job = makeJob({ type: record.type, containers: report.containers, id: record.id });
|
|
239
|
+
job.finish('interrupted', { error: 'serve 重启,任务已中断', result: report });
|
|
240
|
+
handled.push(report);
|
|
241
|
+
}
|
|
242
|
+
return handled;
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// 把请求路径归到某个容器:/api/sessions/<名>/… 与 /api/containers/<名>/…(名字可能是 容器~agent)。
|
|
248
|
+
// 返回 { container, tail };与容器无关返回 null。
|
|
249
|
+
function containerOfPath(pathname, decode = decodeURIComponent) {
|
|
250
|
+
const match = /^\/api\/(?:sessions|containers)\/([^/]+)(\/.*)?$/.exec(pathname);
|
|
251
|
+
if (!match) return null;
|
|
252
|
+
if (match[1] === 'orphans') return null;
|
|
253
|
+
let name = match[1];
|
|
254
|
+
try { name = decode(match[1]); } catch (error) { /* 保持原样 */ }
|
|
255
|
+
return { container: name.split('~')[0], tail: match[2] || '' };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// 任务期间仍允许的只读请求:读消息、读详情(进度在 /api/jobs 与列表里)
|
|
259
|
+
const READONLY_TAILS = [/^\/messages$/, /^\/detail$/];
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* 闸门:返回 null 表示放行,否则返回 { status:409, body }。
|
|
263
|
+
*/
|
|
264
|
+
function gateRequest(registry, method, pathname) {
|
|
265
|
+
const target = containerOfPath(pathname);
|
|
266
|
+
if (!target) return null;
|
|
267
|
+
const job = registry.busyFor(target.container);
|
|
268
|
+
if (!job) return null;
|
|
269
|
+
if (method === 'GET' && READONLY_TAILS.some(re => re.test(target.tail.split('?')[0]))) return null;
|
|
270
|
+
return {
|
|
271
|
+
status: 409,
|
|
272
|
+
body: { error: job.type === 'export' ? '正在导出,完成后可用' : '正在导入,完成后可用', busy: job.type, jobId: job.id }
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
module.exports = { createJobRegistry, JobCancelled, containerOfPath, gateRequest, JOB_ID_RE, jobsDir };
|