@xcanwin/manyoyo 8.6.11 → 8.7.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 +2 -2
- package/bin/manyoyo.js +16 -14
- package/docker/manyoyo.Dockerfile +1 -1
- package/docker/res/claude/settings.json +20 -0
- package/lib/agent-adapters/index.js +18 -1
- package/lib/container-network.js +49 -10
- package/lib/egress-sidecar.js +19 -5
- package/lib/image-agents.js +45 -0
- package/lib/init-config.js +2 -1
- package/lib/name-template.js +52 -0
- package/lib/network-endpoints.js +56 -1
- package/lib/plugin/playwright.js +12 -5
- package/lib/provider-presets.js +86 -0
- package/lib/runtime-resolver.js +13 -1
- package/lib/setup-cli.js +18 -4
- package/lib/setup.js +7 -1
- package/lib/web/AGENTS.md +3 -0
- package/lib/web/agent-queue.js +5 -1
- package/lib/web/auth-store.js +167 -0
- package/lib/web/container-manage.js +2 -2
- package/lib/web/denied-notify.js +5 -0
- package/lib/web/index.html +94 -94
- package/lib/web/rewind.js +75 -0
- package/lib/web/server.js +971 -75
- package/lib/web/snapshots.js +245 -0
- package/lib/web/storage.js +444 -0
- package/lib/web/uploads.js +114 -0
- package/package.json +8 -6
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// 按轮快照与回退:每轮对话前后,在容器里对工作目录各拍一次“影子 git”快照
|
|
4
|
+
//(--git-dir 放在容器能写的 /run/manyoyo/snap.git,--work-tree 指向工作目录),不碰用户自己的 .git。
|
|
5
|
+
// 这是体验功能,不是安全边界:快照在容器里,agent 能改它(和 Claude 自己的检查点一样)。
|
|
6
|
+
// 会读工作目录里的 .gitignore(node_modules 等不进快照);单次超过 SNAPSHOT_TIMEOUT_SECONDS 就放弃,本轮不能回退文件。
|
|
7
|
+
// 所有命令都是 bash 脚本字符串,路径 / 提交号一律经 quote() 或校验后才拼进去。
|
|
8
|
+
|
|
9
|
+
const SNAP_GIT_DIR = '/run/manyoyo/snap.git';
|
|
10
|
+
const SNAPSHOT_TIMEOUT_SECONDS = 3;
|
|
11
|
+
// 限额:没被 .gitignore 忽略的单个文件超过 20MB、或影子仓库超过 1GB,这一轮就不拍快照(显示“目录太大”)。
|
|
12
|
+
// 数据集、构建产物这类大文件每轮改动都会在影子仓库里存一份,不设上限会一直涨到删容器;不做自动清理(会丢掉回退点)
|
|
13
|
+
const MAX_FILE_BYTES = 20 * 1024 * 1024;
|
|
14
|
+
const MAX_REPO_KB = 1024 * 1024;
|
|
15
|
+
const GIT_TIMEOUT_SECONDS = 10;
|
|
16
|
+
const ROUND_CHANGES_MAX_FILES = 500;
|
|
17
|
+
const ROUND_DIFF_MAX_BYTES = 512 * 1024;
|
|
18
|
+
const PREVIEW_FILE_LIMIT = 20;
|
|
19
|
+
const MARK_SHA = '__MANYOYO_SNAP_SHA__';
|
|
20
|
+
const MARK_RAW = '__MANYOYO_SNAP_RAW__';
|
|
21
|
+
const MARK_NUMSTAT = '__MANYOYO_SNAP_NUMSTAT__';
|
|
22
|
+
const MARK_DIFF = '__MANYOYO_SNAP_DIFF__';
|
|
23
|
+
|
|
24
|
+
const SHA_PATTERN = /^[0-9a-f]{40}$/;
|
|
25
|
+
|
|
26
|
+
function isSnapId(value) {
|
|
27
|
+
return typeof value === 'string' && SHA_PATTERN.test(value);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function quote(value) {
|
|
31
|
+
return `'${String(value).replace(/'/g, `'"'"'`)}'`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function assertSnap(value, label) {
|
|
35
|
+
if (!isSnapId(value)) throw new Error(`${label} 不是合法的快照号`);
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// 提交说明只留安全字符
|
|
40
|
+
function safeLabel(label) {
|
|
41
|
+
return String(label || 'snapshot').replace(/[^A-Za-z0-9 _.:-]/g, '').slice(0, 80) || 'snapshot';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 公共前缀:影子仓库的 git 命令。配置不读用户的;hooks / gc / fsmonitor 全关
|
|
45
|
+
function prelude(workDir, gitDir) {
|
|
46
|
+
return [
|
|
47
|
+
`W=${quote(workDir)}`,
|
|
48
|
+
`G=${quote(gitDir)}`,
|
|
49
|
+
'cd -- "$W" 2>/dev/null || { echo SKIP_NO_DIR; exit 0; }',
|
|
50
|
+
'export GIT_DIR="$G" GIT_WORK_TREE="$W" GIT_OPTIONAL_LOCKS=0 GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null GIT_TERMINAL_PROMPT=0',
|
|
51
|
+
'export GIT_CONFIG_COUNT=9',
|
|
52
|
+
'export GIT_CONFIG_KEY_0=user.name GIT_CONFIG_VALUE_0=manyoyo',
|
|
53
|
+
'export GIT_CONFIG_KEY_1=user.email GIT_CONFIG_VALUE_1=manyoyo@localhost',
|
|
54
|
+
'export GIT_CONFIG_KEY_2=safe.directory GIT_CONFIG_VALUE_2="*"',
|
|
55
|
+
'export GIT_CONFIG_KEY_3=core.excludesFile GIT_CONFIG_VALUE_3=/dev/null',
|
|
56
|
+
'export GIT_CONFIG_KEY_4=gc.auto GIT_CONFIG_VALUE_4=0',
|
|
57
|
+
'export GIT_CONFIG_KEY_5=core.hooksPath GIT_CONFIG_VALUE_5=/dev/null',
|
|
58
|
+
'export GIT_CONFIG_KEY_6=core.fsmonitor GIT_CONFIG_VALUE_6=false',
|
|
59
|
+
'export GIT_CONFIG_KEY_7=core.quotepath GIT_CONFIG_VALUE_7=false',
|
|
60
|
+
'export GIT_CONFIG_KEY_8=commit.gpgsign GIT_CONFIG_VALUE_8=false'
|
|
61
|
+
].join('; ');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 拍快照的步骤(不含 prelude):输出 SNAP_SHA 行;超时输出 SNAP_TIMEOUT
|
|
65
|
+
function snapshotSteps(label, limits = {}) {
|
|
66
|
+
const maxFileBytes = Number.isInteger(limits.maxFileBytes) ? limits.maxFileBytes : MAX_FILE_BYTES;
|
|
67
|
+
const maxRepoKb = Number.isInteger(limits.maxRepoKb) ? limits.maxRepoKb : MAX_REPO_KB;
|
|
68
|
+
return [
|
|
69
|
+
'[ -d "$G" ] || env -u GIT_DIR -u GIT_WORK_TREE git init -q --bare "$G" >/dev/null 2>&1 || { echo SNAP_FAIL; exit 0; }',
|
|
70
|
+
'rm -f "$G/index.lock"',
|
|
71
|
+
// 影子仓库太大:不再往里存
|
|
72
|
+
`repo_kb="$(du -sk "$G" 2>/dev/null | cut -f1)"; [ "\${repo_kb:-0}" -le ${maxRepoKb} ] || { echo SNAP_TOO_BIG; exit 0; }`,
|
|
73
|
+
// 没被忽略的大文件(新增或被改过的):列出再逐个看大小;这一步也在 3 秒门槛里,超时同样放弃
|
|
74
|
+
`big="$(timeout -k 1 ${SNAPSHOT_TIMEOUT_SECONDS} sh -c 'git ls-files -z -m -o --exclude-standard | xargs -0 -r stat -c %s 2>/dev/null | while read -r n; do [ "$n" -gt ${maxFileBytes} ] && { echo 1; break; }; done')"; rc=$?`,
|
|
75
|
+
'[ "$rc" = 124 ] || [ "$rc" = 137 ] && { echo SNAP_TIMEOUT; exit 0; }',
|
|
76
|
+
'[ -z "$big" ] || { echo SNAP_TOO_BIG; exit 0; }',
|
|
77
|
+
`timeout -k 1 ${SNAPSHOT_TIMEOUT_SECONDS} git add -A >/dev/null 2>&1; rc=$?`,
|
|
78
|
+
'[ "$rc" = 124 ] || [ "$rc" = 137 ] && { rm -f "$G/index.lock"; echo SNAP_TIMEOUT; exit 0; }',
|
|
79
|
+
// add 失败(比如有读不了的文件)时不能继续:否则 HEAD 还是上一张快照,会被当成“现在”的快照记下来
|
|
80
|
+
'[ "$rc" = 0 ] || { echo SNAP_FAIL; exit 0; }',
|
|
81
|
+
`timeout -k 1 ${SNAPSHOT_TIMEOUT_SECONDS} git commit -q --allow-empty --no-verify -m ${quote(safeLabel(label))} >/dev/null 2>&1; rc=$?`,
|
|
82
|
+
'[ "$rc" = 124 ] || [ "$rc" = 137 ] && { rm -f "$G/index.lock"; echo SNAP_TIMEOUT; exit 0; }',
|
|
83
|
+
'[ "$rc" = 0 ] || { echo SNAP_FAIL; exit 0; }',
|
|
84
|
+
`sha="$(git rev-parse HEAD 2>/dev/null)" || { echo SNAP_FAIL; exit 0; }`,
|
|
85
|
+
`printf '%s\\n%s\\n' ${MARK_SHA} "$sha"`
|
|
86
|
+
];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function buildSnapshotCommand(workDir, label, gitDir = SNAP_GIT_DIR, limits = {}) {
|
|
90
|
+
return [prelude(workDir, gitDir), ...snapshotSteps(label, limits)].join('; ');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** @returns {{sha: string}|{skipped: 'too-large'|'unavailable'}} */
|
|
94
|
+
function parseSnapshotOutput(output) {
|
|
95
|
+
const text = String(output || '');
|
|
96
|
+
if (text.includes('SNAP_TIMEOUT') || text.includes('SNAP_TOO_BIG')) return { skipped: 'too-large' };
|
|
97
|
+
const match = text.match(new RegExp(`${MARK_SHA}\\n([0-9a-f]{40})`));
|
|
98
|
+
return match ? { sha: match[1] } : { skipped: 'unavailable' };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---- 比较两个快照 ----
|
|
102
|
+
|
|
103
|
+
function diffSteps(from, to) {
|
|
104
|
+
const a = assertSnap(from, 'from');
|
|
105
|
+
const b = assertSnap(to, 'to');
|
|
106
|
+
return [
|
|
107
|
+
`printf '%s\\n' ${MARK_RAW}`,
|
|
108
|
+
`timeout ${GIT_TIMEOUT_SECONDS} git diff --name-status -z --no-renames --no-ext-diff --no-textconv ${a} ${b} 2>/dev/null | head -c 1048576`,
|
|
109
|
+
`printf '\\n%s\\n' ${MARK_NUMSTAT}`,
|
|
110
|
+
`timeout ${GIT_TIMEOUT_SECONDS} git diff --numstat -z --no-renames --no-ext-diff --no-textconv ${a} ${b} 2>/dev/null | head -c 1048576`
|
|
111
|
+
];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function sectionAfter(output, mark, nextMark) {
|
|
115
|
+
const start = output.indexOf(`${mark}\n`);
|
|
116
|
+
if (start < 0) return '';
|
|
117
|
+
const from = start + mark.length + 1;
|
|
118
|
+
const end = nextMark ? output.indexOf(`\n${nextMark}\n`, from) : -1;
|
|
119
|
+
return end < 0 ? output.slice(from) : output.slice(from, end);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function parseNameStatus(raw) {
|
|
123
|
+
const parts = raw.split('\0');
|
|
124
|
+
const entries = [];
|
|
125
|
+
for (let i = 0; i + 1 < parts.length; i += 2) {
|
|
126
|
+
const code = parts[i].replace(/^\n/, '');
|
|
127
|
+
const filePath = parts[i + 1];
|
|
128
|
+
if (!code || !filePath || filePath.includes('\n')) continue;
|
|
129
|
+
entries.push({ code: code[0], path: filePath });
|
|
130
|
+
}
|
|
131
|
+
return entries;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function statusOf(code) {
|
|
135
|
+
return code === 'A' ? 'added' : code === 'D' ? 'deleted' : 'modified';
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function buildRoundChangesCommand(workDir, from, to, gitDir = SNAP_GIT_DIR) {
|
|
139
|
+
return [prelude(workDir, gitDir), `[ -d "$G" ] || { echo SNAP_FAIL; exit 0; }`, ...diffSteps(from, to)].join('; ');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function parseRoundChangesOutput(output) {
|
|
143
|
+
const text = String(output || '');
|
|
144
|
+
if (!text.includes(MARK_RAW)) return { ok: false, files: [], truncated: false };
|
|
145
|
+
const counts = new Map();
|
|
146
|
+
for (const record of sectionAfter(text, MARK_NUMSTAT, '').split('\0')) {
|
|
147
|
+
const match = record.replace(/^\n/, '').match(/^(-|\d+)\t(-|\d+)\t(.+)$/s);
|
|
148
|
+
if (match) counts.set(match[3], { added: match[1] === '-' ? null : Number(match[1]), deleted: match[2] === '-' ? null : Number(match[2]) });
|
|
149
|
+
}
|
|
150
|
+
const entries = parseNameStatus(sectionAfter(text, MARK_RAW, MARK_NUMSTAT));
|
|
151
|
+
const files = entries.slice(0, ROUND_CHANGES_MAX_FILES).map(entry => ({
|
|
152
|
+
path: entry.path,
|
|
153
|
+
status: statusOf(entry.code),
|
|
154
|
+
...(counts.get(entry.path) || { added: null, deleted: null })
|
|
155
|
+
}));
|
|
156
|
+
return { ok: true, files, truncated: entries.length > ROUND_CHANGES_MAX_FILES };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function buildRoundDiffCommand(workDir, from, to, filePath, gitDir = SNAP_GIT_DIR) {
|
|
160
|
+
const a = assertSnap(from, 'from');
|
|
161
|
+
const b = assertSnap(to, 'to');
|
|
162
|
+
return [
|
|
163
|
+
prelude(workDir, gitDir),
|
|
164
|
+
`[ -d "$G" ] || { echo SNAP_FAIL; exit 0; }`,
|
|
165
|
+
`printf '%s\\n' ${MARK_DIFF}`,
|
|
166
|
+
`GIT_LITERAL_PATHSPECS=1 timeout ${GIT_TIMEOUT_SECONDS} git diff --no-ext-diff --no-textconv --no-color --no-renames ${a} ${b} -- ${quote(filePath)} 2>/dev/null | head -c ${ROUND_DIFF_MAX_BYTES + 1}`
|
|
167
|
+
].join('; ');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function parseRoundDiffOutput(output) {
|
|
171
|
+
const text = String(output || '');
|
|
172
|
+
if (!text.includes(MARK_DIFF)) return { ok: false, diff: '', truncated: false };
|
|
173
|
+
const body = sectionAfter(text, MARK_DIFF, '');
|
|
174
|
+
const truncated = Buffer.byteLength(body, 'utf8') > ROUND_DIFF_MAX_BYTES;
|
|
175
|
+
return { ok: true, diff: truncated ? Buffer.from(body, 'utf8').subarray(0, ROUND_DIFF_MAX_BYTES).toString('utf8') : body, truncated };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ---- 回退文件 ----
|
|
179
|
+
|
|
180
|
+
// 先拍一张“现在”的快照,再看它和目标快照差在哪:M 会被改回去,A(目标之后新增)会被删掉,D(目标之后被删)会被恢复
|
|
181
|
+
function buildRestorePreviewCommand(workDir, target, gitDir = SNAP_GIT_DIR) {
|
|
182
|
+
const t = assertSnap(target, 'target');
|
|
183
|
+
return [
|
|
184
|
+
prelude(workDir, gitDir),
|
|
185
|
+
`[ -d "$G" ] || { echo SNAP_FAIL; exit 0; }`,
|
|
186
|
+
...snapshotSteps('rewind preview'),
|
|
187
|
+
`printf '%s\\n' ${MARK_RAW}`,
|
|
188
|
+
`timeout ${GIT_TIMEOUT_SECONDS} git diff --name-status -z --no-renames --no-ext-diff --no-textconv ${t} "$sha" 2>/dev/null | head -c 1048576`
|
|
189
|
+
].join('; ');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function parseRestorePreviewOutput(output) {
|
|
193
|
+
const text = String(output || '');
|
|
194
|
+
const snap = parseSnapshotOutput(text);
|
|
195
|
+
if (!snap.sha || !text.includes(MARK_RAW)) return { ok: false, reason: snap.skipped || 'unavailable' };
|
|
196
|
+
const entries = parseNameStatus(sectionAfter(text, MARK_RAW, ''));
|
|
197
|
+
const count = code => entries.filter(entry => entry.code === code).length;
|
|
198
|
+
// 目标 → 现在:A 是目标之后新增的(要删),D 是目标之后删掉的(要恢复),其余算修改
|
|
199
|
+
const files = entries.slice(0, PREVIEW_FILE_LIMIT).map(entry => ({
|
|
200
|
+
path: entry.path,
|
|
201
|
+
action: entry.code === 'A' ? 'delete' : entry.code === 'D' ? 'restore' : 'modify'
|
|
202
|
+
}));
|
|
203
|
+
return { ok: true, current: snap.sha, total: entries.length, modify: entries.length - count('A') - count('D'), delete: count('A'), restore: count('D'), files };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// 回退前先拍快照(这样回退本身也能撤销),再把工作目录恢复成目标快照:
|
|
207
|
+
// 目标里有的文件改回去,“现在有、目标里没有”的文件删掉;被 .gitignore 忽略的文件不动
|
|
208
|
+
function buildRestoreCommand(workDir, target, gitDir = SNAP_GIT_DIR) {
|
|
209
|
+
const t = assertSnap(target, 'target');
|
|
210
|
+
return [
|
|
211
|
+
prelude(workDir, gitDir),
|
|
212
|
+
`[ -d "$G" ] || { echo SNAP_FAIL; exit 0; }`,
|
|
213
|
+
...snapshotSteps('before rewind'),
|
|
214
|
+
// 目标快照里有而现在被改了的文件
|
|
215
|
+
`git cat-file -e ${t}^{commit} 2>/dev/null || { echo RESTORE_FAIL; exit 0; }`,
|
|
216
|
+
// 目标是空目录的快照(第一轮开始前工作目录里什么都没有)时没有文件可检出,`checkout -- .` 会报 pathspec 不匹配,直接跳过
|
|
217
|
+
`if [ -n "$(git ls-tree -r --name-only ${t} 2>/dev/null | head -c 1)" ]; then timeout ${GIT_TIMEOUT_SECONDS * 6} git checkout -q ${t} -- . 2>/dev/null || { echo RESTORE_FAIL; exit 0; }; fi`,
|
|
218
|
+
// 目标之后新增的文件:影子仓库容器能写,路径不可信——绝对路径和带 .. 的一律跳过
|
|
219
|
+
`git diff -z --name-only --diff-filter=A --no-renames ${t} "$sha" 2>/dev/null | while IFS= read -r -d '' f; do case "$f" in /*|..|../*|*/../*|*/..) continue;; esac; rm -f -- "$f"; done`,
|
|
220
|
+
'echo RESTORE_OK'
|
|
221
|
+
].join('; ');
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function parseRestoreOutput(output) {
|
|
225
|
+
const text = String(output || '');
|
|
226
|
+
const snap = parseSnapshotOutput(text);
|
|
227
|
+
if (!text.includes('RESTORE_OK')) return { ok: false, reason: snap.skipped || 'failed' };
|
|
228
|
+
return { ok: true, before: snap.sha || '' };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
module.exports = {
|
|
232
|
+
SNAP_GIT_DIR,
|
|
233
|
+
SNAPSHOT_TIMEOUT_SECONDS,
|
|
234
|
+
isSnapId,
|
|
235
|
+
buildSnapshotCommand,
|
|
236
|
+
parseSnapshotOutput,
|
|
237
|
+
buildRoundChangesCommand,
|
|
238
|
+
parseRoundChangesOutput,
|
|
239
|
+
buildRoundDiffCommand,
|
|
240
|
+
parseRoundDiffOutput,
|
|
241
|
+
buildRestorePreviewCommand,
|
|
242
|
+
parseRestorePreviewOutput,
|
|
243
|
+
buildRestoreCommand,
|
|
244
|
+
parseRestoreOutput
|
|
245
|
+
};
|