mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 11:58:34 +08:00
fix(skill): persist skill workspace on the existing data volume; skip binary entries in ZIP packages (#273)
This commit is contained in:
parent
5ebdccb1f6
commit
be7bbd9644
@ -87,6 +87,15 @@ MATECLAW_OAUTH_OPENAI_CALLBACK_BIND_HOST=
|
|||||||
# - /your/host/path:/data/wiki
|
# - /your/host/path:/data/wiki
|
||||||
MATE_WIKI_ALLOWED_SOURCE_ROOTS=
|
MATE_WIKI_ALLOWED_SOURCE_ROOTS=
|
||||||
|
|
||||||
|
# ── Skill 工作区目录 ─────────────────────────────────────────────
|
||||||
|
# 已安装的 skill、运行时积累的 LESSONS.md、skill 运行产物都落在这个目录。
|
||||||
|
# 默认(容器内)已指向 /app/data/skills,由 docker-compose 的 server_data 卷
|
||||||
|
# 持久化,容器重启不丢,无需额外挂卷。一般无需修改。
|
||||||
|
# 内置 skill 由 JAR classpath 每次启动现场释放,挂空卷也不会丢内置文件。
|
||||||
|
# 仅当你想把 skill 目录放到别处(如独立的 bind mount)时才覆盖此项,
|
||||||
|
# 并记得在 docker-compose.yml 的 volumes 里把对应宿主机目录挂进容器。
|
||||||
|
MATECLAW_SKILL_WORKSPACE_ROOT=
|
||||||
|
|
||||||
# ── Maven 镜像(国内加速)─────────────────────────────────────────
|
# ── Maven 镜像(国内加速)─────────────────────────────────────────
|
||||||
# 在中国大陆构建时取消注释,将 Aliyun 仓库优先级提前,大幅提速 mvn 拉包。
|
# 在中国大陆构建时取消注释,将 Aliyun 仓库优先级提前,大幅提速 mvn 拉包。
|
||||||
# 空值(默认)使用 US Maven Central → Google CDN → Aliyun 的顺序。
|
# 空值(默认)使用 US Maven Central → Google CDN → Aliyun 的顺序。
|
||||||
|
|||||||
@ -100,6 +100,10 @@ services:
|
|||||||
# 示例:MATE_WIKI_ALLOWED_SOURCE_ROOTS=/data/wiki,/opt/docs
|
# 示例:MATE_WIKI_ALLOWED_SOURCE_ROOTS=/data/wiki,/opt/docs
|
||||||
# 记得同步在 volumes 里把宿主机路径挂进容器。
|
# 记得同步在 volumes 里把宿主机路径挂进容器。
|
||||||
MATE_WIKI_ALLOWED_SOURCE_ROOTS: ${MATE_WIKI_ALLOWED_SOURCE_ROOTS:-}
|
MATE_WIKI_ALLOWED_SOURCE_ROOTS: ${MATE_WIKI_ALLOWED_SOURCE_ROOTS:-}
|
||||||
|
# Skill 工作区根目录。放在 /app/data 下,让现有的 server_data 卷一并持久化
|
||||||
|
# 已安装的 skill、运行时积累的 LESSONS.md 以及 skill 运行产物,容器重启不丢。
|
||||||
|
# 内置 skill 仍由 JAR classpath 每次启动现场释放,空卷不会丢内置文件。
|
||||||
|
MATECLAW_SKILL_WORKSPACE_ROOT: ${MATECLAW_SKILL_WORKSPACE_ROOT:-/app/data/skills}
|
||||||
# Chromium needs a real /dev/shm. Docker defaults to 64MB which causes
|
# Chromium needs a real /dev/shm. Docker defaults to 64MB which causes
|
||||||
# SIGBUS / "Target page closed" errors under load. 2GB is the usual
|
# SIGBUS / "Target page closed" errors under load. 2GB is the usual
|
||||||
# recommendation for Playwright / headless chrome.
|
# recommendation for Playwright / headless chrome.
|
||||||
@ -108,10 +112,11 @@ services:
|
|||||||
- "18080:18088" # host:container — app listens on 18088 inside the container
|
- "18080:18088" # host:container — app listens on 18088 inside the container
|
||||||
- "1455:1455"
|
- "1455:1455"
|
||||||
volumes:
|
volumes:
|
||||||
|
# server_data covers /app/data — H2 DB, wiki-uploads, AND the skill
|
||||||
|
# workspace (MATECLAW_SKILL_WORKSPACE_ROOT=/app/data/skills above), so a
|
||||||
|
# single volume persists everything. No separate skills volume needed.
|
||||||
- server_data:/app/data
|
- server_data:/app/data
|
||||||
- skills_data:/root/.mateclaw/skills
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mysql_data:
|
mysql_data:
|
||||||
server_data:
|
server_data:
|
||||||
skills_data:
|
|
||||||
|
|||||||
@ -29,6 +29,8 @@ import java.util.zip.ZipInputStream;
|
|||||||
* <li>Zip Slip path traversal</li>
|
* <li>Zip Slip path traversal</li>
|
||||||
* <li>Per-file ≤1MB, total ≤50MB</li>
|
* <li>Per-file ≤1MB, total ≤50MB</li>
|
||||||
* <li>Only SKILL.md / references/ / scripts/ entries are kept</li>
|
* <li>Only SKILL.md / references/ / scripts/ entries are kept</li>
|
||||||
|
* <li>Binary entries are skipped with a WARN — bundle storage is text-only,
|
||||||
|
* so decoding them as text would persist corrupted content</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
*
|
*
|
||||||
* <p>Extraction is two-pass: the entire archive is buffered in memory first
|
* <p>Extraction is two-pass: the entire archive is buffered in memory first
|
||||||
@ -229,6 +231,23 @@ public class ZipSkillFetcher {
|
|||||||
throw new IOException("Total extracted size exceeds 50MB limit");
|
throw new IOException("Total extracted size exceeds 50MB limit");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skill bundles persist file contents as text (mate_skill_file
|
||||||
|
// is a TEXT column; SkillBundle carries Map<String,String>).
|
||||||
|
// Decoding a binary entry (.png/.woff/.zip/compiled helper, …)
|
||||||
|
// as text replaces every invalid byte with U+FFFD, so the file
|
||||||
|
// would be stored permanently corrupted and "restored" broken
|
||||||
|
// on every sync. Binary resources are not supported in a bundle
|
||||||
|
// today, so skip them with a clear WARN instead of silently
|
||||||
|
// mangling them — matches how unknown root-level files are
|
||||||
|
// already handled below. (Root-level binaries were already
|
||||||
|
// dropped; this also covers binaries nested in scripts/ and
|
||||||
|
// references/, which previously slipped through corrupted.)
|
||||||
|
if (isLikelyBinary(bytes)) {
|
||||||
|
log.warn("[ZipSkillFetcher] Skipping binary entry (not supported in skill bundles): {}", entryName);
|
||||||
|
zis.closeEntry();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
String content = new String(bytes, charset);
|
String content = new String(bytes, charset);
|
||||||
String normalizedName = entryPath.toString().replace('\\', '/');
|
String normalizedName = entryPath.toString().replace('\\', '/');
|
||||||
String fileName = entryPath.getFileName().toString();
|
String fileName = entryPath.getFileName().toString();
|
||||||
@ -289,6 +308,27 @@ public class ZipSkillFetcher {
|
|||||||
return new ExtractedSkill(skillMdContent, references, scripts);
|
return new ExtractedSkill(skillMdContent, references, scripts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Heuristic binary detector: an entry is treated as binary if a NUL byte
|
||||||
|
* (0x00) appears within the inspected prefix. UTF-8 and GBK text never
|
||||||
|
* contain a NUL, while virtually every binary format (PNG/WOFF/ZIP/class/
|
||||||
|
* native executable) carries one near the start — this is the same cheap,
|
||||||
|
* reliable test git uses to decide "is this a text file". Inspecting only a
|
||||||
|
* prefix keeps it O(1) for large entries.
|
||||||
|
*/
|
||||||
|
private static boolean isLikelyBinary(byte[] bytes) {
|
||||||
|
if (bytes == null || bytes.length == 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
int limit = Math.min(bytes.length, 8000);
|
||||||
|
for (int i = 0; i < limit; i++) {
|
||||||
|
if (bytes[i] == 0x00) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Classify a root-level file (sibling of SKILL.md, no directory prefix)
|
* Classify a root-level file (sibling of SKILL.md, no directory prefix)
|
||||||
* by extension. Returns {@code "scripts"} / {@code "references"} for
|
* by extension. Returns {@code "scripts"} / {@code "references"} for
|
||||||
|
|||||||
@ -148,7 +148,11 @@ mateclaw:
|
|||||||
mode: ${MATECLAW_TOOLS_DISCLOSURE_MODE:progressive}
|
mode: ${MATECLAW_TOOLS_DISCLOSURE_MODE:progressive}
|
||||||
skill:
|
skill:
|
||||||
workspace:
|
workspace:
|
||||||
root: ${user.home}/.mateclaw/skills
|
# Skill workspace root. Override with MATECLAW_SKILL_WORKSPACE_ROOT to
|
||||||
|
# relocate it onto a persistent volume — in Docker this is pointed at
|
||||||
|
# /app/data/skills so the existing server_data volume persists installed
|
||||||
|
# skills, accumulated LESSONS.md, and skill runtime files across restarts.
|
||||||
|
root: ${MATECLAW_SKILL_WORKSPACE_ROOT:${user.home}/.mateclaw/skills}
|
||||||
auto-init: true
|
auto-init: true
|
||||||
delete-policy: archive
|
delete-policy: archive
|
||||||
disclosure:
|
disclosure:
|
||||||
|
|||||||
@ -205,6 +205,44 @@ class ZipSkillFetcherTest {
|
|||||||
assertEquals("#!/bin/sh\n", ex.scripts().get("setup.sh"));
|
assertEquals("#!/bin/sh\n", ex.scripts().get("setup.sh"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private record RawEntry(String name, byte[] content) {}
|
||||||
|
|
||||||
|
private static byte[] zipOfRaw(List<RawEntry> entries) throws IOException {
|
||||||
|
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||||
|
try (ZipOutputStream zos = new ZipOutputStream(baos, StandardCharsets.UTF_8)) {
|
||||||
|
for (RawEntry e : entries) {
|
||||||
|
zos.putNextEntry(new ZipEntry(e.name()));
|
||||||
|
zos.write(e.content());
|
||||||
|
zos.closeEntry();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return baos.toByteArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Binary entry under scripts/ is skipped, not stored corrupted (#273)")
|
||||||
|
void binaryEntryInScriptsIsSkipped() throws IOException {
|
||||||
|
// A PNG header carries a NUL byte; decoding it as UTF-8 would replace
|
||||||
|
// bytes with U+FFFD and persist a corrupted "text" file. The fetcher
|
||||||
|
// must drop it (with a WARN) while keeping the legitimate text script.
|
||||||
|
byte[] pngBytes = new byte[]{(byte) 0x89, 'P', 'N', 'G', 0x00, 0x1A, 0x0A, 'x'};
|
||||||
|
byte[] zip = zipOfRaw(List.of(
|
||||||
|
new RawEntry("pkg/SKILL.md", SKILL_MD.getBytes(StandardCharsets.UTF_8)),
|
||||||
|
new RawEntry("pkg/scripts/run.py", "print('ok')\n".getBytes(StandardCharsets.UTF_8)),
|
||||||
|
new RawEntry("pkg/scripts/logo.png", pngBytes),
|
||||||
|
new RawEntry("pkg/references/font.woff", new byte[]{'w', 'O', 'F', 'F', 0x00, 0x01})
|
||||||
|
));
|
||||||
|
|
||||||
|
ZipSkillFetcher.ExtractedSkill ex = ZipSkillFetcher.extract(new ByteArrayInputStream(zip));
|
||||||
|
|
||||||
|
// Text script survives; both binaries are dropped (no corrupted entry).
|
||||||
|
assertEquals(Map.of("run.py", "print('ok')\n"), ex.scripts(),
|
||||||
|
"Binary logo.png must not be stored; the text script stays");
|
||||||
|
assertTrue(ex.references().isEmpty(),
|
||||||
|
"Binary font.woff must not be stored as corrupted text");
|
||||||
|
assertFalse(ex.scripts().containsKey("logo.png"));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("GBK-encoded entry names (Windows-authored zip) fall back from UTF-8 to GBK")
|
@DisplayName("GBK-encoded entry names (Windows-authored zip) fall back from UTF-8 to GBK")
|
||||||
void extractsGbkEncodedNames() throws IOException {
|
void extractsGbkEncodedNames() throws IOException {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user