# Skill Content MD5 Calculation Spec

本文定义 ClawManager / OpenClaw / Hermes 之间统一使用的 `content_md5` 计算方式。Hermes agent 的 inventory 上报、`collect_skill_package` 上传、`install_skill` 安装后校验，都必须使用同一套算法。

## 结论

`content_md5` 不是 zip 文件本身的 MD5，也不包含 zip entry 顺序、压缩等级、mtime、权限等元数据。它是 skill 目录内容的规范化 MD5。

上传 zip 时，zip 必须包含且只包含一个顶层 skill 目录。ClawManager 会先剥掉这个顶层 skill 目录，再对目录内部内容计算 `content_md5`。

例如上传包结构为：

```text
weather/
  skill.json
  src/
    main.py
```

实际参与 MD5 的路径是：

```text
skill.json
src
src/main.py
```

注意：只剥掉 zip 的顶层 skill 目录 `weather/` 一次，不要再剥掉 skill 内部的 `src/`、`lib/`、`dist/` 等目录。

## 规范化规则

1. 以 skill 根目录作为基准，收集所有普通文件。
2. 路径统一使用 POSIX `/` 分隔符。
3. 去掉路径开头的 `./`，并做 clean 处理。
4. 跳过空路径、`.`、`..`、包含 `..` 越界语义的路径。
5. 跳过任意路径段以 `.` 开头的文件和目录，例如 `.git/config`、`.cache/a`、`.DS_Store`。
6. 目录项不直接从文件系统读取，而是由文件路径的父目录推导出来。
7. 将目录项和文件项放在同一个列表中，按规范化路径字典序升序排序。
8. 对每个目录项写入以下字节：

```text
{relative_path}\n
dir\n
```

9. 对每个文件项写入以下字节：

```text
{relative_path}\n
file\n
{raw_file_bytes}
\n
```

10. 对上述连续字节流计算 MD5，输出 32 位小写 hex 字符串。

不要改写文件内容。不要转换换行符，不要格式化 JSON，不要忽略空文件，不要把文件权限、mtime、owner、zip 压缩参数写入 digest。

## Hermes Agent Checklist

Hermes agent 需要检查下面几个点：

- inventory 上报的 `content_md5` 应该对 `/config/.hermes/skills/{skill_name}` 目录内部内容计算。
- 上传 `collect_skill_package` zip 时，zip 内应该有一个顶层目录 `{skill_name}/`。
- inventory 阶段和上传阶段必须使用同一份 skill 目录内容计算 MD5。
- 如果本地目录是 `/config/.hermes/skills/weather/src/main.py`，参与 MD5 的路径必须是 `src/main.py`，不是 `weather/src/main.py`，也不是 `main.py`。
- 如果 `content_md5` 和 ClawManager 返回的 expected 不一致，先检查是否多剥或少剥了顶层目录，其次检查是否把隐藏目录、文件元数据或 zip bytes 算进去了。

## Python Reference Implementation

下面实现可直接给 Hermes agent 端对齐算法：

```python
import hashlib
from pathlib import Path


def _is_hidden_relative_path(rel: str) -> bool:
    return any(part.startswith(".") for part in rel.split("/"))


def skill_content_md5(skill_dir: str | Path) -> str:
    root = Path(skill_dir).resolve()
    files: dict[str, bytes] = {}
    dirs: set[str] = set()

    for path in sorted(root.rglob("*")):
        if not path.is_file():
            continue

        rel = path.relative_to(root).as_posix()
        if rel.startswith("./"):
            rel = rel[2:]
        if not rel or rel == "." or rel.startswith("../") or _is_hidden_relative_path(rel):
            continue

        files[rel] = path.read_bytes()
        parts = rel.split("/")
        for i in range(1, len(parts)):
            parent = "/".join(parts[:i])
            if parent and not _is_hidden_relative_path(parent):
                dirs.add(parent)

    entries: dict[str, str] = {rel: "file" for rel in files}
    for rel in dirs:
        entries[rel] = "dir"

    digest = hashlib.md5()
    for rel in sorted(entries):
        digest.update(rel.encode("utf-8"))
        digest.update(b"\n")
        if entries[rel] == "dir":
            digest.update(b"dir\n")
        else:
            digest.update(b"file\n")
            digest.update(files[rel])
            digest.update(b"\n")

    return digest.hexdigest()
```

## Zip Upload Reference

上传给 ClawManager 的 zip 应保持一个顶层目录：

```text
weather/
  skill.json
  src/main.py
```

Hermes agent 在本地计算 MD5 时应对目录 `/config/.hermes/skills/weather` 调用 `skill_content_md5()`。不要对 zip 文件调用 MD5。

如果 agent 需要在上传前自检，可以先把 zip 解开，确认去掉 `weather/` 后得到的文件列表与本地计算使用的相对路径一致。

## 常见 MD5 不匹配排查

| 现象 | 可能原因 | 处理 |
|------|----------|------|
| `expected abc got def` 且 expected 来自 inventory | Agent inventory 与 collect 使用了不同目录快照 | 确保 collect 前目录未变化，且两次调用同一 `skill_content_md5()` |
| got 值每次不同 | 对 zip 文件 bytes 做 MD5，或 zip 内文件顺序/元数据参与计算 | 只对 skill 目录内容做规范化 MD5，见上文算法 |
| expected 含顶层目录名 | inventory 多剥了一层 skill 根目录 | 基准目录应为 `/config/.openclaw/workspace/skills/{name}/` 内部 |
| 隐藏文件导致偏差 | `.git`、`.cache` 等被计入或遗漏不一致 | 跳过任意以 `.` 开头的路径段 |
| collect 命令 succeeded 但 Hub 仍 unknown | skill-scanner 未部署或扫描失败 | 检查 `skill-scanner` Pod 与 blob `scan_status` |
