Merge pull request #266 from chedong/fix/install-sh-260-265 fix(install): harden installer updates, webroot deploys and data ownership
fix(install): port installer hardening onto the PHPMAN_HOME layout Rebased onto master's current installer (src/ + PHPMAN_HOME + cli/). Addresses the still-applicable findings from #260-#265: - persist webroot/no-server/data-owner in .git/phpman-install-state so --update re-deploys without repeating flags (#261) - add --data-owner/--cache-owner, chown data dirs and existing files, verify writability and source readability (#262) - patch PHPMAN_HOME into the deployed copy with var_export instead of sed, write it atomically, and stop patching the tracked clone copy so git pull --ff-only works again (#263) - rewrite argument parsing: --opt=value, missing-value/unknown/relative path/non-numeric port errors, --no-start alias (#265) - pipefail, explicit clone/pull failures, post-clone checks, php -l and a non-empty target guard (#260) #264 (mbstring/FTS5) is already fixed on master and is left as-is. Tested with stubbed php/git: fresh install, fresh webroot + data owner, state-driven update, reindex, failed pull, dirty file handling, missing extensions and argument validation. Co-Authored-By: Claude code 2.1.309 with deepseek-v4.1-flash-1017 <noreply@taotoken.net>
fix(json,cache): 非法 UTF-8 不再产生 0 字节 JSON;负行不再被每次请求续期
两项来自 /code-review (max) 的 findings(#3 已由上游 9f79615 修掉,不重复)。
## noMatchNotice() 的 0 字节 JSON
`$parameter` 直接来自 URL,`normalizeParameter()` 不要求合法 UTF-8。对
`/pydoc/%FF/json` 这类请求,`json_encode()` 返回 false,而 `: string` 返回类型
把 false 强转成 '' —— 于是 404 带 `Content-Type: application/json`、正文
**0 字节**。这正是 #252 加这个 notice 要消灭的「空 JSON」,只是从另一条路径
又回来了。
两个 json_encode 调用都加上 `JSON_INVALID_UTF8_SUBSTITUTE`(PHP 7.2+,本项目
下限 8.1,安全):非法序列替换成 U+FFFD,正文仍是合法 JSON、`results` 仍是
空数组。新增 test/unit/test_no_match_notice.php(12 条)。把 util.php 退回
旧版本再跑,**9 passed,3 failed** —— 三条失败正是 0 字节/非法 JSON 那组。
## man/perldoc/info 的负行每次请求都被续期
三个分支在内容为空时都写:
$cache = new PageCache();
$cache->set($mode, $name, $section, $format, null, 'not_found');
而缓存命中一条 not_found 行时 `cacheOrExecute()` 也返回 '',于是**每个请求都
再写一次**。`set()` 是 UPSERT,会刷新 updated_at —— 1 天的 TTL 永远走不完,
一次瞬时失败变成永久 404(crawler 只要还在访问就一直续期)。
新增 `cacheNotFoundOnce()`:行已存在就什么都不做,让 TTL 的含义回到
「距第一次未命中一天」,而不是「距最后一次请求一天」。已有 found 行同样不动
(既不会被降级,时间戳也不变)。三个调用点改为使用它。fetchRow 本身会在
TTL 过期时删除并返回 null,所以过期后下一次请求会正常重写。
test_negative_cache_body.php 增加 6 条断言:只写一次、二次未命中不改
updated_at、found 行不被降级;并加了一条**对照断言**钉住旧行为 —— 直接
`set()` 确实会把 updated_at 往后推,正是被替换掉的那个 bug。
## 不做 RENDERER_VERSION bump
notice 是在 cache 写入之后加的,负行里存的仍是空正文,措辞每次请求现算;
TTL 修复不改变任何成功渲染的字节。两者都不需要失效已有缓存。
验证:make test 通过;php test/run_all.php 751 passed, 0 failed。
Co-Authored-By: Claude code 2.1.309 with deepseek-v4.1-flash-1017 <noreply@taotoken.net>
fix(cache): 负缓存守卫改按 status 判断,不再让 FTS 正文绕过它
`PageCache::set()` 的守卫原本是:
if ($status === 'not_found' && ($content === null || $content === '')) { ...保护已有的 found 行... }
「只在正文为空时保护」这个前提在 #247 之后不成立了 —— 超时的 `pydoc3 -k` 现在会
退到 FTS 索引并用它产出一个**非空**正文(#248),于是守卫被跳过,UPSERT 会用
not_found 行覆盖掉并发的 found 行。
竞态窗口很大,因为兜底本身要跑 **20 秒**:
A: 页面渲染失败 → 进兜底扫描
B: 同一名字渲染成功 → 写入 found 行
A: 20 秒后写 not_found 行(带 FTS 正文)→ **覆盖 B 的 found**
后果是一个合法页面的缓存渲染结果被换成「未找到 + 搜索结果」,持续 10 分钟。
守卫的注释写的正是这个场景("Any transient content-generation failure can race
a reader that has already cached the good result")—— 现在它防不住了。
修法:只按 status 判断,不再看正文是否为空。不会误伤合法的负到负覆盖 —— 已有的
not_found 行经 get() 返回哨兵,isNotFound() 为真,内层条件不成立,写入照常。
新增 5 条回归断言,并**验证过它们在旧守卫下失败**(found 行被替换成
`###NOT_FOUND###`)、在新守卫下通过。738 passed / 0 failed。
Co-Authored-By: Claude code 2.1.287 with deepseek-flash <noreply@taotoken.net>
docs: 新增 09-QUALITY-GATES — 四层验证、原始材料、G1–G9 门 把本轮复盘整理成可执行的质量门设计:L0 语法/单测 → L1 行为契约 (RenderResult)→ L2 参考实现差分(groff/pydoc/perldoc/ri)→ L3 外部标准/第三方(W3C/WCAG/RFC/MCP、外部监控与扫描)。 同时记录「自证不够」的四次实例(opcache 装死、realpath 300s 理论、 #249 的慢渲染前提、#251 的 logcheck 撒谎)与原始材料清单;路线图 对应 issue #253–#257,并在 00-INDEX / 08 里建立交叉引用。
fix(pydoc,config): 超时不再丢弃 FTS 兜底正文;配置模板不再引用未定义常量
两处来自 /code-review (max) 的高优先级 findings。
## pydoc 超时丢弃已算出的结果
`getPydocSearchPage()` 在 `pydoc3 -k` 超时(exit 124)后会退到 FTS pydoc 索引,
并且**可能已经拼出真实结果**。但调用点写的是:
$body = getPydocSearchPage(..., $timedOut);
return $timedOut ? null : $body;
于是这份正文被丢掉,`cacheOrSearchFallback()` 收到 null,写入空负行,页面告诉
用户「什么都没匹配」—— 而匹配其实刚算出来。
`null` 同时承载了「没有正文」和「无法确定」两种含义,所以无法表达
「有正文,但结论不确定」。给 `cacheOrSearchFallback()` 增加第 8 个引用参数
`$indeterminate`:
if ($content !== null && !$indeterminate) {
$cache->set(..., $content, 'not_found'); // 确定未命中,24h
} else {
if (没有行) $cache->set(..., (string)$content, 'not_found',
PHPMAN_CACHE_TTL_TIMEOUT); // 不确定,600s,带正文
}
调用点的闭包把 `$timedOut` 写进 `$indeterminate` 并原样返回 `$body`。ri 调用点
仍传 7 个参数,`$indeterminate` 默认 null,行为不变。
效果:超时但有 FTS 结果时,用户看到建议列表(仍 404,因为无法确认页面存在),
行按 600s 过期而不是 24h,也不会把「暂时算不出来」冻成一天。
## 配置模板的隐藏雷
`phpman.config.php.example` 里 `PHPMAN_MF_LOG_FILE` 原本写成:
define('PHPMAN_MF_LOG_FILE', PHPMAN_LOG_DIR . '/phpman_mf.log');
但用户配置在 `src/config.php` 定义派生路径**之前**加载,单独取消注释这一行会
让每次请求在 bootstrap 抛 Undefined constant。改成字面路径,并在
PHPMAN_HOME 段写明这个约束(派生常量不能在 user config 的右侧使用)。
没有改加载顺序:派生路径必须在用户配置之后定义,否则 `PHPMAN_LOG_DIR` 等
覆盖项会失效。约束写进模板是正确做法。
## 不做 RENDERER_VERSION bump
受影响的只有 pydoc 超时兜底页,而它写入的是 600s 负行;旧行 10 分钟内自然过期。
bump 会让全部 210 天的 found 行一起失效,对一个错误路径的修复不成比例。
## 验证
make test 通过;php test/run_all.php 728 passed, 0 failed。
把两个代码文件退回 HEAD~1 再跑本测试:**45 passed, 1 failed** —— TTL 那条准确
抓到旧行为(旧代码把带正文的负行按 24h 写入)。正文被丢弃的那一半在
phpMan.php 的内联闭包里,要真实 `pydoc3` 超时才会触发,单测没有覆盖;它由新的
返回契约(返回 body + 独立 `$indeterminate`)保证,并靠代码审查确认。
Co-Authored-By: Claude code 2.1.308 with deepseek-flash <noreply@taotoken.net>
docs: 按 code-review (max) 结果同步 CHANGELOG 与设计文档
对最新代码做了一轮 15 项审查(全部核实,见 /code-review 输出),本次只更新
文档,不改代码行为。
## CHANGELOG [Unreleased]
- 新增 #248 条目:`phpManExec()` 给所有渲染子进程加上界
(`PHPMAN_CMD_TIMEOUT` 15s),`pydoc3 -k` 单独用 `PHPMAN_PYDOC_K_TIMEOUT`
20s,超时负行用 `PHPMAN_CACHE_TTL_TIMEOUT` 600s(只写一次,不续期)。
- 新增 #249 条目:logcheck 5xx 分族 + 打印窗口范围 + pairing source;
`LOGCHECK_DAYS` / `LOGCHECK_RECENT_LINES` 现在真的通过 make 传进 ssh。
- 修正三处与已上线行为矛盾的旧文案:
* 遥测落盘从 `phpman_error.log` 改为 `phpman_mf.log`(#251 已迁移);
* `PHPMAN_MF_LOG_MS=0` 不能单独关掉遥测 —— 两个阈值是 OR 关系;
* pydoc 超时不再"什么都不写",而是写 600s 短 TTL 行。
## 设计文档
- 03-CACHE:补 600s 超时 TTL、带正文的负行 (`getWithStatus()`)、
"超时行只写一次" 三条;原文只写了 24h not_found 和无正文负行。
- 04-SEARCH:pydoc 兜底不再无条件跑 `pydoc3 -k`(#247 对含点名字跳过、
超时退 FTS),新增 §4.6 说明,并订正 5 处流程图/优先级表。
- 01-PRODUCT:同步 `getPydocSearchPage` 的裸词 / 含点名字差异。
- 05-PLAN:`RENDERER_VERSION` "currently 2" → 3。
## 配置模板
- `phpman.config.php.example`:
* TTL 说明改为当前行为(search 负行用 found TTL;超时行 600s;
删除已随 emoji 层消失的 "never expires");
* 补 `PHPMAN_CACHE_TTL_TIMEOUT` 与 `PHPMAN_PYDOC_K_TIMEOUT`;
* `PHPMAN_MF_LOG_FILE` 那行原本以 `PHPMAN_LOG_DIR` 拼接,但用户配置在
`src/config.php` 定义派生路径**之前**加载 —— 单独取消注释会让每次请求
抛 Undefined constant。改为字面路径并写明原因。
## 另修:10e2dc3 引入的两条错误警告
该提交称 "整条 v3.7 tag 链不在 master 上 / merge-base 为空" 与
"本地与远程 tag 对象不同"。实测均不成立:
git merge-base master v3.7.12 → 4ab3aea(非空)
merge-base --is-ancestor v3.7.12 → 成功(b7037a5f…4ab3aea 全在 master)
git rev-parse v3.7.12 / ls-remote → 同为 4ddc556 → 4ab3aea
改为陈述核实结果;保留"旧 checkout 的 tag 可能未更新"这个真实隐患
(73cab20 / 016b815 仍在对象库),并给出 `git ls-remote` 的核对方法。
验证:make test 通过;php test/run_all.php 720 passed, 0 failed;
所有代码块围栏配平;docs/ 相对链接全部可解析。
Co-Authored-By: Claude code 2.1.308 with deepseek-flash <noreply@taotoken.net>
在 Telegram 发
/update升 2026.10.7,bot 回复"已提交"但版本没动。日志只有update.run … status=skipped和update-recovery-pending: Package publication journal does not match its installation。清掉 10-19 遗留的孤儿 journal 后,又卡在 Node 版本门(本机 26.10.0 < 2026.10.7 要求的 26.12.1);换成 Node 26.12.1 后升级成功,但 LaunchAgent 被 bootout 没重新拉起,openclaw gateway install --force恢复。最后 journal 停在publication-complete,要跑一次recovery.mjs retire才算真正收尾——否则下一次/update还会被拦。
/update 什么都没做。它在准入闸门 assertNoPendingPackageActivation() 就返回了,日志表现为 update.run … status=skipped。闸门的判据是 readPackageActivationContinuation(),只有两种情况放行:
<anchor> 与 <anchor>.control 都不存在;isPackageActivationComplete() 成立 —— phase === "anchor-retired" 且有 unlink-helper intent。matchesInstallation() 只比对 parentIdentity / journalParentIdentity / journalIdentity 三个 device:inode 字符串。10-19 那份 journal 记的是 16777220:*,APFS 卷重挂后实际是 16777221:*,inode 全对也没用 → inspectRemount() 抛 Package settlement inode or device mapping changed.。openclaw update repair --yes 救不了。2026.10.7 的 engines 是 >=24.22.1 <25 || >=26.12.1,本机 Node 26.10.0 不满足,package-install 跑 scripts/preinstall-package-manager-warning.mjs exit 1,最终 global-install-failed。--omit=optional 重试同样失败(卡的不是可选依赖)。state-migrated-no-rollback、Gateway did not settle),LaunchAgent 被 bootout 且没有重新 bootstrap。openclaw gateway install --force 一行恢复。openclaw update status 的 Package recovery。若是 publication-complete / aborted 而不是 complete,下次 /update 必挂;recovery.mjs repair 是空操作,retire 才有效。在 Telegram 里发 /update,bot 回得挺痛快,但 openclaw --version 一直是 2026.10.5。
docs(plan): 落实 code-review 5 项订正——v3.7.9–3.7.12 段的历史准确性 /code-review (high) 对 9fd077c 的 5 个 findings 逐项核实后修订: 1. 本地/远程 tag 对象不同:本地 v3.7.11→73cab20、v3.7.12→016b815, 远程回填版→df83482/4ab3aea,git fetch 不覆盖已有 tag。补 ⚠️ 括注 写明两条 commit 各是什么、核对用 git ls-remote <tag>^。 2. 遗漏主要变更:Fix #129 (part 1–6) macOS apropos 系列与 --build-index 清页缓存(016b815)补进 bullet。 3. 拓扑是孤立历史:git merge-base master v3.7.12 为空——整条 tag 链 (含 3.7.13–19)都不在 master 上,是 reset/rebase 后废弃的支线。 补 ⚠️ 括注,读历史要按 git log <tag> 走。 4. "tag 日期到此为止"与 v3.7.20/21 矛盾:它们打于 06-17,比 v4.0 (06-16)还晚一天。改为「本节时间线只覆盖这五个 tag」,并写明 20/21 是 batch_enhance 过渡 tag(v4.10 已移除)。 5. 顺序图省略 commit:73cab20..4ab3aea 区间实有 15 个提交,补括注 两个未标记的 SEE ALSO 修复(5d3c34b/7ebbf16)+ merge + README 截图更新(1fec571)。 纯文档改动,不动代码,不涉及渲染输出。 Co-Authored-By: Claude Code 2.1.308 with deepseek-v4-pro <noreply@taotoken.net>
docs(plan): 订正 v3.7.9–3.7.12 的日期,并标注 11/12 是回填 tag `### v3.7.9–3.7.12` 的日期原写 2026-06-11 → **06-14**,但 tag 实际只到 06-13 (v3.7.9 打于 06-11 07:04,v3.7.12 打于 06-13 14:06)。 同时把这一段最容易绕人的地方写清楚:**v3.7.11 与 v3.7.12 是回填的 tag, 编号顺序与提交顺序相反。** v3.7.13–v3.7.19 2026-06-11 15:30–17:02 一口气打完 v3.7.11 / v3.7.12 2026-06-13 01:56 / 14:06 两天后才补打 拓扑上 **v3.7.13 的提交是 v3.7.12 提交的祖先**,实际提交顺序是 `13 → 14 → 15 → 16 → 17 → 18 → 19 → 11 → 12`,中间还有一个 `Merge remote: resolve v3.7.11 version conflict` 的合并提交 —— 当时就撞过版本号。 所以这一段不能按 `git log` 的先后读版本先后,要按 tag 读。 起因是核对 v3.7.12 这个 tag 的来历时发现的:tag 本身是人打的 (tagger=chedong,2026-06-13 14:06:34),它指向的提交 4ab3aea (man page fallback cheat.sh → man7.org)署名是 Claude Opus 4.7。 纯文档改动,不动代码,不涉及渲染输出。 Co-Authored-By: Claude code 2.1.304 with deepseek-flash <noreply@taotoken.net>