跳过主要内容
L

aidsh

lswn/aidsh

DeepSeek Harness plugin: normalise author and creation date on generated docx/xlsx/pptx/pdf and friends.

安装

dsh plugin --profile web add github:lswn/aidsh

README

dsh-office-meta-stamp

DeepSeek Harness 插件:给自动生成的文档按你的名字和生成日期署名。

生成 docx / xlsx / pptx / pdf 时,工具链往往把自己的名字写进文件属性(python-docx、openpyxl、Steve Canny,甚至 2013 年的模板日期)。这个插件在文件落盘后,只改“作者”和“创建/修改时间”,其余字节一个不动。作者名已经是你自己的文件,完全不动。

本 README 分两部分:第一部分给人读;第二部分是给 AI / 维护者的复现规格。 零运行时依赖,只用 Node 内置模块。


第一部分 · 给人看

它解决什么问题

你看到的实际来源
属性里作者是 python-docxdocProps/core.xml 的 dc:creator
创建时间是 2013 年库自带的模板日期
备注里写着 generated by python-docxdc:description
最后修改者是 Steve Cannycp:lastModifiedBy

插件把这些换成你配置的作者和文件自身的生成时间,并删掉自定义属性里的生成器痕迹。

安装

# 1) 按包名(发布到 npm 之后)
dsh plugin --profile desktop add dsh-office-meta-stamp

# 2) 本地目录(开发时用)
dsh plugin --profile desktop add link:"D:\Plugins\dsh-office-meta-stamp"

# 3) Web 侧栏「插件 → 添加插件」里贴包名 / Git 地址 / 本地目录

link: 只建目录联接,不复制文件。插件零依赖,所以本地链接安装不需要额外解析任何包。

装完重启一次 DSH:Host 侧插件只在启动时加载。

装完是什么样

  1. 插件加载时如果 author 为空,自动用当前系统用户名。
  2. 之后每次生成文档,落盘后属性自动变成:作者 = 你,创建/修改 = 生成那一刻。
  3. 想换名字,改 profile 里的一行配置再重启。

没有弹窗,没有打断。

配置

当前 DSH 版本的插件页只为官方配置插件渲染表单,第三方插件没有设置界面。 配置直接写在 profile 的 cordis.patch.yml:

- id: office-meta-stamp
  name: dsh-office-meta-stamp
  config:
    author: "你的名字"
    debug: false

文件位置:%USERPROFILE%\.dsh\profiles\<profile>\cordis.patch.yml。改完重启一次。

字段默认说明
author系统用户名写进文件的作者名
lastModifiedBy空留空表示与 author 相同
notifySkippedtrue无法改写时是否写 WARN 日志
allowMacrofalse放行带宏的 OOXML 包(会破坏签名)
maxBytes50 MB超过直接跳过
dryRunfalse只记录,不落盘
pdfModemetadata-onlyoff 表示完全不处理 PDF
debugfalse把每个决策写进系统临时目录的 office-meta-stamp-debug.log

支持、拒绝、跳过

支持(会改)

.docx .xlsx .pptx .xlsb      → docProps/core.xml
.odt .ods .odp               → meta.xml
.epub                        → OPF 的 dc:creator / dcterms:modified
.rtf                         → {\info} 组
.pdf                         → 追加新的 /Info(增量更新)

拒绝(生成前就拦下,不会产出属性错误的文件)

.doc .dot .xls .xlt .ppt .pot    旧版 OLE 复合文档,就地改扇区不可逆
.docm .xlsm .pptm                改动会破坏数字签名,默认拒绝
.wps .et .dps                    WPS 私有/旧格式,结构不公开
.pages .numbers .key             iWork,元数据在 IWA 里,无安全写路径

同一会话里第一次拦下会提示改用 .docx;再试只回“该格式不支持”,不再给方案。

跳过(文件保留原样,只记日志)

加密 / ODF 带数字签名 / 文件损坏 / 超过 maxBytes / 被占用 / 结构无法识别。

什么时候会处理

插件看不到意图,只能读工具调用的参数:

  • 判早了:写 gen.py 时脚本内容里有 报告.docx,会在写脚本那步就处理。无实际影响。
  • 判漏了:文件名由变量拼出来,参数里没有扩展名,抓不到。兜底是工具执行后对相关目录做限时扫描(1.5 秒防抖、只看 20 秒内变动的文件、深度 2 层)。

两条边界,避免误伤:

  • 只读工具不触发:read / glob / grep / present 不会引起落章。
  • 只处理刚生成的文件:参数命中的文件,修改时间超过 10 分钟的跳过。

安全承诺

  • 作者名已经是你的文件完全不动——日期不动、字节不动。你自己在 Word 里做的文档不会被“改成”插件认为对的样子。
  • 只替换 zip 里的目标条目,其余字节原样搬运;PDF 只追加,不动原字节。
  • 先写临时文件、校验通过再原子替换;失败保留原文件。
  • 不动文件系统时间。
  • 不动 app.xml 的 Application、ODF 的 meta:generator、PDF 的 Producer。
  • 写 README 这类正文里提到旧格式的文件,不会再被门禁误拦。

已知限制

  • 加密文件(OOXML 加密码后其实是 OLE 容器)一律跳过。
  • PDF 的 /Info 位于对象流中时跳过,不重建(避免丢字段)。
  • 混合交叉引用(/XRefStm)的 PDF 跳过。
  • 不回溯修改已存在的文件;不支持多作者 / 按项目切换。
  • xlsx 的 Application 仍是 openpyxl。想彻底干净,生成后在 Excel/WPS 里打开另存一次。

常见问题

装完没反应? 重启一次 DSH;确认装到了你在用的 profile。

作者还是旧名字? 看 cordis.patch.yml 里 author,或者重启让系统用户名回退生效。

某个文件没改? 打开 debug: true,看临时目录的日志;notifySkipped 也会写 WARN。


第二部分 · 给 AI 读(复现规格)

目标:读完这一节,能在没有本仓库的情况下重新实现同等行为,或安全地修改它。

0. 复现清单

node test/run.mjs          # 期望:17 passed, 0 failed
node test/adversarial.mjs  # 期望:34 passed, 0 failed

零运行时依赖,只用 node:zlib、node:fs、node:path、node:os。Node ≥ 20。

1. 运行契约

export const name = 'office-meta-stamp'
export const inject = ['tools']
export function apply(ctx, config) { /* 注册两个事件处理器 */ }

没有 Config 导出:宿主不渲染第三方插件的表单,配置由 loader 从 YAML 直接传入。

package.json 必须含:

"type": "module",
"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }

cordis.patch.yml 向 profile 插入条目,id 必须与插件用于持久化的键一致:

- insert:
    - id: office-meta-stamp
      name: dsh-office-meta-stamp

两个事件(返回值即决策):

ctx.on('tools/pre-execute', async (exec, next) => ({ kind: 'allow' } | { kind: 'deny', reason }))
ctx.on('tools/post-execute', async (exec, result, next) => decision)
  • exec.name / exec.arguments(已冻结,不可改写)。
  • pre-execute 返回对象即决策,不调用 next()。
  • post-execute 必须先 await next();处理全包在 try/catch 里,绝不能让插件异常导致工具调用失败。

2. 不变量

  1. 不破坏:先在内存构造新 buffer、自检、再原子替换;失败保留原文件。
  2. 字节最小改动:zip 只换目标条目;PDF 只追加。禁止“解包 → 重组”。
  3. 不猜:结构不认识就 SkipError,绝不尝试修复。
  4. 幂等:作者名已等于目标值 → changed:false,字节与日期都不动。
  5. 属性保留:替换元素时保留原属性(命名空间可能声明在元素上,见 §10)。
  6. 不改文件系统时间:fs.utimes 还原 atime/mtime。
  7. 时间来自文件:created = birthtime ?? mtime,modified = mtime;不用 Date.now()。
  8. 只碰刚生成的文件:只读工具不触发;参数命中的文件 mtime 早于 10 分钟前则跳过。

3. 模块地图

文件职责
lib/index.js入口:配置归一化、两个事件、防抖扫描、日志
lib/config.jsDEFAULTS 与 unwrap();没有 schema
lib/classify.js从参数结构里找扩展名与路径;按工具决定扫描范围
lib/normalize.js嗅探 → 适配器 → 幂等 → 校验 → 原子写
lib/zip.js外科式 ZIP 读写(EOCD/CD/LFH/CRC32)
lib/xml.js保留属性的元素文本替换
lib/time.jsW3CDTF / ODF / PDF / RTF 四种时间格式
lib/debug.js可选的 JSONL 诊断日志
lib/adapters/*.js每种格式的 stamp(buffer, meta)

适配器契约:

export const id = 'opc'
export const extensions = ['.docx']
export function stamp(buffer, meta) // → { buffer, format, mode? } | throw SkipError
// meta = { author, lastModifiedBy, created: Date, modified: Date }

4. 数据流

工具调用
  ├─ pre-execute → scopedArguments(name, args) → classify → rejected[] → deny(两段式)
  └─ 执行 → post-execute
        └─ afterTool(只读工具直接返回)
             ├─ collectWorkingDirs(scoped)
             ├─ pathsIn(scoped, resolve)(只取 mtime ≤10min 的)
             ├─ stampOne(file)
             └─ scheduleSweep()  // 1.5s 防抖,depth=2,20s 窗口,最多 400 个文件

stampOne → stampFile → normalizeBuffer:

stat(size, mtime, birthtime) → 超限跳过 → author 为空跳过
→ readFile(EBUSY 退避重试)
→ sniff: PK..(zip) / D0CF11E0(ole) / %PDF / {\rtf
→ 容器与适配器一致性校验
→ 幂等检查(仅作者名)
→ adapter.stamp → zip 结果再 readZip 验证
→ 写 .<name>.<pid>.<ts>.stamp.tmp → rename(EBUSY/EPERM/EACCES 退避 5 次)
→ utimes 还原

5. 算法

5.1 ZIP 外科改写

读:从尾部向前 ≤65557 字节找 EOCD(0x06054b50);拒绝 ZIP64 与多卷;遍历中央目录(0x02014b50),校验每条本地头(0x04034b50)。

写:按 localOffset 排序;未改条目按原始字节区间整段复制(区间终点 = 下一条目的 localOffset,最后一条用 cdOffset),额外字段、数据描述符、压缩参数全部保留。改动条目重建本地头(flags & ~0x0008,sizes 写入本地头),deflateRawSync,压缩后不小于原文则改 store。最后重建中央目录与 EOCD,保留归档注释。

5.2 OPC(docx/xlsx/pptx/xlsb)

  1. 有加密条目 → Skip。
  2. docProps/core.xml 必须存在。
  3. setElementText 逐字段:dc:creator、cp:lastModifiedBy、dcterms:created、dcterms:modified、cp:revision=1;删 dc:description。
  4. 有 docProps/custom.xml → 删条目,并从 _rels/.rels 与 [Content_Types].xml 清掉引用。

5.3 ODF / EPUB

  • ODF:有 documentsignatures.xml 或 manifest 含 encryption-data → Skip。改 meta.xml 的 meta:initial-creator、dc:creator、meta:creation-date、dc:date、meta:editing-cycles。
  • EPUB:META-INF/container.xml → <rootfile full-path> → OPF;改 dc:creator、dc:date、<meta property="dcterms:modified">(EPUB3 强制,必须同步)。

5.4 RTF

定位 {\info;括号扫描(跳过 \x 转义)找组尾;组内替换 \author / \creatim / \revtim 的控制字参数,缺失则插入。无 info 组时在 {\rtf1 头之后插入。非 ASCII 用 \uN?(有符号 16 位),增补平面拆成代理对。

5.5 PDF 增量更新

  1. 取最后一个 startxref → 偏移;该处是 xref 表或 N 0 obj 交叉引用流。
  2. 从 trailer / 流字典取 /Root、/Info、/ID;/Encrypt 或 /XRefStm → Skip。
  3. 新 Info 号 = maxObjNum + 1;原 /Info 存在时必须找到其明文 << >> 并继承字段(Title/Producer 等);找不到(在对象流里)→ Skip。
  4. 追加:Info 对象 → 新 xref(表:xref\nN 1\n<off> 00000 n;流:/Type /XRef /W [1 4 2] /Index [N 2],deflate 7 字节/条)→ trailer 带 /Prev → startxref → %%EOF。
  5. 自检:重解析 startxref、确认 /Info N 0 R 可达、原字节是结果前缀。

字符串:ASCII 用 (...),非 ASCII 用 UTF-16BE 十六进制 <FEFF...>。日期 D:YYYYMMDDHHmmSS±HH'mm'。

6. 判定与门禁

  • 扩展名按长度倒序匹配(.docx 不会变成 .doc),后缀断言 (?![a-z0-9])。
  • 扫描范围按工具区分:write/edit 等目标明确的工具只看 path 类字段;shell 类工具才扫整条命令。否则写一份提到旧格式的 README 会被自己拦下。
  • 门禁只拦 REJECTED_EXTENSIONS;allowMacro 打开时带宏包走 OPC 适配器。
  • 两段式文案按 (sessionId, extension) 计数:首次给替代格式,其后只回“不支持”。

7. 跳过原因

原因触发
文件已加密zip 条目加密位 / PDF /Encrypt / ODF manifest
数字签名ODF documentsignatures.xml
旧版二进制魔数 D0 CF 11 E0
超过体积上限> maxBytes
被占用读取或 rename 重试耗尽
结构无法识别缺 core.xml / meta.xml / OPF / trailer

8. 测试规格

test/run.mjs(17 项,主路径):

  1. zip 只改一个条目,其余字节不变
  2. OLE 输入被拒绝
  3. docx:作者/时间写入、dc:description 消失、dc:title 保留
  4. docx 幂等
  5. odt:meta.xml 更新、meta:generator 保留
  6. epub:dc:creator + dcterms:modified
  7. rtf:CJK 转义、无关 info 项保留
  8. rtf:无 info 组时插入
  9. pdf:原字节是前缀、/Info 4 0 R、/Prev
  10. pdf:中文名 <FEFF...>
  11. 分类器不把 .docx 当 .doc
  12. 拒绝/支持清单分流
  13. 两个钩子注册;两次拒绝文案不同
  14. 系统用户名回退
  15. allowMacro 放行后仍落章
  16. post-execute 端到端落章
  17. 磁盘落章后 mtime 不变

test/adversarial.mjs(34 项,边界):

数据描述符 / 归档注释 / store 条目 / 1 MiB 条目 / CD 额外字段 / 中文部件名 / BOM / XML 元字符 / 控制字符 / RTF 转义花括号 / xref 流 PDF / /Info 字段继承 / PDF 幂等 / 作者已正确时原样跳过(docx / pdf / rtf) / 加密 PDF / 混合引用 PDF / .docx 实为 OLE / 签名 ODF / 未知 zip / 空文件 / 大写扩展名 / 只读工具不触发 / 旧文件不触发 / 正文提及时不误拦门禁 / 带空格路径 / 相对路径 / 增补平面字符 / volatile 引用解包。

9. 扩展一种格式

  1. 新建 lib/adapters/x.js,导出 id、extensions、stamp(buffer, meta)。
  2. 在 lib/adapters/index.js 的 ADAPTERS 加一行。
  3. 在 test/run.mjs 加 fixture 与至少两条断言(改对了 + 其余字节不变)。

10. 已知坑

  • 命名空间声明在元素上:openpyxl 把 xmlns:dc 写在 <dc:creator> 上;替换时丢属性会让 lxml 报 Namespace prefix dc ... not defined。必须“只换文本、保留属性”。
  • 数据描述符:改写条目要 flags & ~0x0008 并写 sizes。
  • EPUB 的裸 meta:用 meta 标签做 upsert 会误伤 <meta name="cover">,只能插入。
  • PDF /Info 在对象流里:重建会丢 Producer/Title,宁可跳过。
  • utimes 必须在 rename 之后:顺序反了 mtime 会被改回“现在”。
  • 文件名带空格:先取引号内完整片段,再退回按空白切分。
  • PDF 重复落章:每次追加一节 xref,必须靠作者名幂等拦住。
  • 锁定文件在读阶段就 EBUSY:读也要重试并映射成可识别的跳过原因。
  • 门禁扫正文会误伤:写文档时提到旧扩展名不该被拦,扫描范围要按工具区分。
  • npm pack 默认全收:必须用 files 白名单。

11. 术语

  • OPC:Office Open XML 的包格式(zip + [Content_Types].xml + relationships)。
  • CFB/OLE:旧版 Office 的复合文档容器。
  • 增量更新:在 PDF 末尾追加一节并让 startxref 指过去,原字节不变。
  • 外科改写:只替换容器里的目标条目,其余字节原样复制。

许可

MIT

相关插件