Skip to content

fix(launchd): 本机 npm test 会打死外挂——沙箱调用不得操作真实自启作业 - #10

Merged
Seller-1990 merged 6 commits into
mainfrom
fix/launchd-uninstall-isolation
Oct 6, 2026
Merged

Seller-1990 merged 6 commits into
mainfrom
fix/launchd-uninstall-isolation

Conversation

@Seller-1990

Copy link
Copy Markdown
Owner

症状

外挂时不时就「无法连接本机 controller(外挂未运行?):Failed to fetch」,重开 App 能恢复,但很快又复现。

根因

tools/companion/launchd.cjs 的 uninstall() 里,plist 路径尊重 dir 覆盖,但 bootout 的目标是模块常量 LABEL:

const p = plistPath(o.dir);                                   // 尊重 dir
launchctl(['bootout', `gui/${uid()}/${LABEL}`]);              // 硬编码 LABEL

LABEL 是固定常量,重定向只能改 plist 的路径,改不掉 launchd 域里的作业名。于是:

  • test/launchd.test.js 用 L.uninstall({ dir }) 指向临时目录,实际打的却是用户真实的作业;
  • launchctl bootout 会终止该作业的进程并把它从 launchd 卸载,KeepAlive 监督随作业一起消失;
  • uninstall 从不重新 bootstrap ⇒ 进程被杀且永不自动重启,直到用户手动重开一次 App。

所以在本机跑一次 npm test 就会打死外挂。这不是「动不动」,是必然;用户重开 App 后又被下一次测试打死,才表现为间歇。

CI 结构性看不见:test job 跑在 ubuntu-latest,没有 /bin/launchctl,execFileSync 抛 ENOENT 被 catch (_) 吞掉;macOS job 不跑 npm test。

修复

uninstall / status / ensureLoaded 一律先判定「目标 plist 是否就是用户真实那份」(canTouchLaunchd),不是就完全不碰 launchd 域,只操作自己沙箱里的文件。

判定用路径而不是「有没有传 dir」,这样 dir 显式指向真实目录、或经 ZCODE_ADVISOR_LAUNCHD_DIR 重定向的情形都能正确识别。路径比较用 canonicalPath()(优先 realpath 父目录)而非 path.resolve,因为 macOS 上 /var、/tmp 都是符号链接——只做 path.resolve 会把经这些路径进来的真实 plist 误判成沙箱,导致真实卸载被静默跳过(功能失效)。

同时把「静默吞掉 ENOENT」换成显式的 process.platform / uid() 判断——静默吞掉正是本 bug 在 CI 上无法暴露的原因。

顺带修复

  • 测试日志污染:test/health.test.js、test/companion-zcode.test.js 等会把 本机 API 就绪:http://127.0.0.1:0/api/status、迁移 0.2.17… 写进真实生产日志 ~/.zcode/advisor-companion.log,淹没真实故障。现已隔离到临时目录,并加静态守卫测试防止回归。

验证

  • npm test:540 项,0 失败。
  • 跑测试前后 ~/.zcode/advisor-companion.log 零增长。
  • 真实 LaunchAgent 在整轮测试后仍 state = running、pid 不变。
  • 反向防线:真实路径必须继续 bootout(否则「卸载」功能本身就废了),已加测试锁死。

回归测试

  • 沙箱 uninstall({dir}) / ZCODE_ADVISOR_LAUNCHD_DIR 重定向:launchctl 调用数为 0。
  • 沙箱 install({dir, now:true}):不 bootstrap、skipped=true、plist 仍落盘。
  • status({dir}):queried=false,不把真实运行态冒充沙箱态。
  • 指向真实目录的符号链接必须判为真实作业;沙箱目录必须判为沙箱。

真机故障:动不动就「无法连接本机 controller(外挂未运行?):Failed to fetch」。

根因是 launchd.cjs 的 uninstall():LABEL 是模块常量,plistPath(o.dir) 尊重 dir 覆盖,
但 bootout 硬编码 `gui/<uid>/local.zcode.advisor`,完全忽略 dir。于是
test/launchd.test.js:180/186 传沙箱 dir 调用 uninstall 时,bootout 打的是用户**真实**
的自启作业。launchctl bootout 会终止该作业的进程并把它从 launchd 卸载,KeepAlive
监督随作业一起消失 —— 进程被杀且**永远不会自动重启**,直到用户重开一次 App。

所以本机每跑一次 `npm test`,外挂就被确定性打死一次(不是「动不动」,是必死);
用户重开 App 后又能用,下次测试再打死,表现为间歇性。CI 结构性看不见:
test job 跑在 ubuntu-latest,/bin/launchctl 不存在 → ENOENT 被 `catch (_)` 吞掉。

修复:
- 新增 realPlistPath()/managesRealAgent(p):以**路径**判定目标是否为用户真实那份
  (而非「有没有传 dir」,这样显式指向真实目录或经 ZCODE_ADVISOR_LAUNCHD_DIR
  重定向都能正确识别)。
- 新增 canTouchLaunchd(p):仅 darwin + 有 uid + 目标为真实 plist 时才碰 launchd 域。
  显式判断取代「抛 ENOENT 再静默吞掉」——后者正是本 bug 在 CI 无法暴露的原因。
- uninstall()/status()/ensureLoaded() 三处同类缺陷一并收口;status() 增加 queried
  字段,区分「没查过」与「查过、没加载」,不再把真实作业的态冒充沙箱 plist 的态
  (装了 agent 的开发机上「已写入但未加载」断言必红)。

顺带修复生产日志污染:controller.cjs:135 在 require 期固化 LOG_FILE,6 个测试
require controller 却未隔离该 env,导致测试噪音(startApi 的「本机 API 就绪」、
saveUserConfig 的 0.2.17 迁移行)写进用户真实日志,把真实故障淹没、并让「外挂从未
启动」被误读成「外挂反复启动」。

回归防线:
- test/launchd.test.js 用子进程打桩 launchctl,断言沙箱 uninstall 调用**零次**
  launchctl(已实测:对修复前代码该用例失败,对修复后通过)。
- 反向断言真实路径仍会 bootout(防止把卸载功能修坏)。
- test/static-guards.test.js 新增守卫:require controller 的测试必须隔离
  ZCODE_ADVISOR_COMPANION_LOG。

验证:npm test 531 项全绿(530 pass / 1 既有 macOS-only skip);在真实 agent
运行时跑完整套件,进程 pid 前后不变;生产日志增量为 0。
ocr 复审意见的修复(上一提交 773134d 的后续):

1. [high/bug] managesRealAgent 只做 path.resolve,不解析符号链接。
   macOS 上 /var → /private/var、/tmp → /private/tmp 都是链接:经这些路径
   传进来的「真实 plist」会被判成沙箱,于是 install/uninstall/status 静默
   跳过 launchd 域操作——卸载与自启安装这两个功能本身失效。
   改为 canonicalPath():优先 realpath 父目录(plist 可能尚未落盘,父目录
   一定存在),退回 realpath 自身,最后退回 path.resolve。
   两个方向都不再误判:指向真实目录的链接被认成真实(该 bootout),
   沙箱仍被认成沙箱(不碰 launchd 域)。

2. [high/bug] 原 catch (_) 连 os.homedir() 的异常一起吞掉,返回「不是真实
   作业」。那会让 install 照旧把 plist 落盘却永远不 bootstrap——正是本 bug
   的另一种形态(文件在、作业不在、用户以为装好了)。现在只捕获路径定位
   失败,并明确写 stderr。

3. [high/test] ensureLoaded 的「跳过」分支是 install 侧的主修复点,原先没有
   回归测试。新增 probeInstallNow + 断言:沙箱 install --now 不调用任何
   launchctl、loaded=false、skipped=true、warn 匹配、plist 仍落盘。

4. [medium] 补齐信号语义:ensureLoaded 的三态返回契约写进注释;install 透传
   skipped;uninstall 增加 skipped(与「bootout 试过但作业未加载」区分);
   status 的 loaded/queried 含义写进注释。

5. 新增符号链接回归测试:指向真实目录的链接必须判为真实作业;沙箱目录
   (即便经 /var 链接)必须判为沙箱。

验证:npm test 540 项 0 失败;跑测试前后 ~/.zcode/advisor-companion.log 零增长;
真实 LaunchAgent 在整轮测试后仍 running(pid 不变)。
@github-actions

github-actions Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

🔍 OpenCodeReview found 16 issue(s) in this PR.

  • ✅ Successfully posted inline: 2 comment(s)
  • 📝 In summary (no line info): 4 comment(s)
  • ⏭️ Skipped (overlap with history): 10 comment(s)

⚠️ 1 warning(s) occurred during review.


maintainability · medium

📄 plugins/zcode-advisor/tools/companion/launchd.cjs

⚠️ GitHub could not post this as an inline comment: No line information provided

install、uninstall 和 status 函数的返回值契约在 skipped 和 queried 字段上存在不一致。虽然逻辑清晰,但建议统一返回值契约中的字段命名和含义,以避免调用方混淆。例如,可以考虑将 queried 统一为 skippedQuery 或其他更一致的命名方式,并确保所有函数的返回值契约文档化。


maintainability · medium

📄 plugins/zcode-advisor/tools/companion/launchd.cjs

⚠️ GitHub could not post this as an inline comment: No line information provided

在 uninstall 函数中,skipped 变量的初始化和赋值逻辑与 install 函数类似,存在相同的潜在问题。如果 canTouchLaunchd(p) 返回 false,skipped 变量将被赋值为 true,但如果 canTouchLaunchd(p) 返回 true,skipped 变量将保持 undefined。

建议明确初始化 skipped 变量为 false,并确保其在所有情况下都有明确的值。例如:

let skipped = false;
if (canTouchLaunchd(p)) {
  try { launchctl(['bootout', `gui/${uid()}/${LABEL}`]); bootedOut = true; } catch (_) { /* 未加载:忽略 */ }
} else {
  skipped = true; // 明确赋值
}
💡 Suggested Change

Before:

let skipped = false;
  if (canTouchLaunchd(p)) {
    try { launchctl(['bootout', `gui/${uid()}/${LABEL}`]); bootedOut = true; } catch (_) { /* 未加载:忽略 */ }
  } else {
    skipped = true;

After:

let skipped = false;
  if (canTouchLaunchd(p)) {
    try { launchctl(['bootout', `gui/${uid()}/${LABEL}`]); bootedOut = true; } catch (_) { /* 未加载:忽略 */ }
  } else {
    skipped = true;

style · low

📄 plugins/zcode-advisor/tools/companion/launchd.cjs

⚠️ GitHub could not post this as an inline comment: No line information provided

在 install 函数中,skipped 变量的初始化逻辑存在问题。当 ensureLoaded 返回的 skipped 为 true 时,skipped 变量被正确赋值,但如果 ensureLoaded 返回的 skipped 为 false 或未定义,skipped 变量将保持 undefined。

建议在初始化时明确设置 skipped 为 false,并确保其在所有情况下都有明确的值。例如:

let skipped = false;
const r = ensureLoaded(written.path, written.changed);
loaded = r.loaded;
warn = r.warn || '';
skipped = r.skipped === true;
💡 Suggested Change

Before:

let skipped = false;
  const r = ensureLoaded(written.path, written.changed);
  loaded = r.loaded;
  warn = r.warn || '';
  skipped = r.skipped === true;

After:

let skipped = false;
const r = ensureLoaded(written.path, written.changed);
loaded = r.loaded;
warn = r.warn || '';
skipped = r.skipped === true;

maintainability · low

📄 plugins/zcode-advisor/tools/companion/launchd.cjs

⚠️ GitHub could not post this as an inline comment: No line information provided

在 ensureLoaded 函数中,当 canTouchLaunchd(plistFile) 返回 false 时,函数返回的 skipped 字段为 true,但返回值中的 loaded 字段仍然为 false。虽然逻辑正确,但建议在注释中明确说明 loaded 和 skipped 的关系,以避免调用方误解。例如:

// 返回契约(三种形状,靠 skipped / warn 区分「跳过」与「失败」):
//   { loaded:false, skipped:true, warn }          —— 故意跳过域操作(沙箱/非 darwin/无 uid),此时 loaded 为 false
//   { loaded:false, warn }                        —— 真失败(bootstrap 抛错),此时 skipped 为 false
💡 Suggested Change

Before:

  if (!canTouchLaunchd(plistFile)) {
    // 只落盘,由调用方决定是否需要别的加载方式;不谎报「已加载」。
    return { loaded: false, skipped: true, warn: '目标不是用户真实 LaunchAgent,跳过 launchd 加载(避免影响真实自启作业)」 };

After:

  if (!canTouchLaunchd(plistFile)) {
    // 只落盘,由调用方决定是否需要别的加载方式;不谎报「已加载」。
    // 返回契约:{ loaded:false, skipped:true, warn } —— 故意跳过域操作(沙箱/非 darwin/无 uid),此时 loaded 为 false
    return { loaded: false, skipped: true, warn: '目标不是用户真实 LaunchAgent,跳过 launchd 加载(避免影响真实自启作业)」 };

⚠️ Warnings:

  • tools/companion/launchd.cjs (review_round_failed): round 2: LLM completion error: POST "https://x666.me/v1/chat/completions": 429 Too Many Requests {"code":"","message":"You have reached the request limit: maximum 25 requests in 5 minutes (request id: 202610060503379460793598268d9d6l297SImx)","type":"new_api_error"}

Comment on lines +134 to +135
try { return path.join(fs.realpathSync(dir), base); } catch (_) { /* 父目录不存在 */ }
try { return fs.realpathSync(p); } catch (_) { /* 文件也不存在 */ }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

performance · high
在 canonicalPath() 函数中,使用 fs.realpathSync() 可能存在文件系统竞争风险。如果在执行期间目录或文件被删除或移动,可能导致后续操作失败或误判。建议考虑使用异步版本并添加重试机制,或在调用后立即验证路径是否有效。

try {
real = realPlistPath();
} catch (err) {
process.stderr.write(`[launchd] 无法定位用户 LaunchAgents 目录,跳过 launchd 域操作:${(err && err.message) || err}\n`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security · medium
在 managesRealAgent() 函数中,虽然已经处理了 os.homedir() 抛出的错误,但错误信息直接输出到 stderr,可能包含敏感路径信息。虽然已避免直接泄露详细错误信息,但建议进一步简化错误信息,仅输出通用的提示信息(例如 "无法定位用户 LaunchAgents 目录"),避免任何潜在的信息泄露。

// 显式判断 platform/uid 而不是靠 execFileSync 抛 ENOENT 再吞掉——「静默吞掉」
// 正是本 bug 在 CI 上无法暴露的原因。
function canTouchLaunchd(p) {
return process.platform === 'darwin' && uid() !== null && managesRealAgent(p);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · medium
canTouchLaunchd() 函数中仅检查 process.platform === 'darwin',但未明确排除 macOS 模拟器或非真实硬件环境。建议添加额外的平台验证逻辑,确保仅在真实 macOS 硬件上执行 launchd 操作。

}
}
return { ok: true, path: written.path, changed: written.changed, loaded };
return { ok: true, path: written.path, changed: written.changed, loaded, skipped };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test · low
在 install() 和 uninstall() 函数中,虽然增加了 skipped 字段来区分操作是否被跳过,但缺少对 skipped 状态的明确文档说明或示例。建议在函数注释中明确说明 skipped 的含义及其与 loaded、bootedOut 等字段的关系,以便开发者正确理解和使用。

}
const state = /state = (\w+)/.exec(detail);
return { path: p, exists, loaded, state: state ? state[1] : '' };
return { path: p, exists, loaded, queried, state: state ? state[1] : '' };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style · low
在 status() 函数中,虽然增加了 queried 字段来区分是否查询过 launchd 域,但缺少对 queried 字段的明确说明。建议在函数注释中明确说明 queried 的含义,以及其与 loaded 字段的关系,以便开发者正确理解返回结果。

Comment on lines +134 to +135
try { return path.join(fs.realpathSync(dir), base); } catch (_) { /* 父目录不存在 */ }
try { return fs.realpathSync(p); } catch (_) { /* 文件也不存在 */ }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security · high
在 canonicalPath 函数中,确保 fs.realpathSync 的异常处理不会泄露敏感路径信息。例如,如果 fs.realpathSync 抛出异常,应确保返回的路径不会暴露用户主目录或其他敏感信息。

Suggestion:

Suggested change
try { return path.join(fs.realpathSync(dir), base); } catch (_) { /* 父目录不存在 */ }
try { return fs.realpathSync(p); } catch (_) { /* 文件也不存在 */ }
try {
const resolvedDir = fs.realpathSync(dir);
return path.join(resolvedDir, base);
} catch (err) {
// 如果父目录不存在,返回一个安全的默认路径或抛出更明确的错误
return path.resolve(p);
}
try {
return fs.realpathSync(p);
} catch (err) {
// 如果文件不存在,返回一个安全的默认路径
return path.resolve(p);
}

Comment on lines +146 to +151
try {
real = realPlistPath();
} catch (err) {
process.stderr.write(`[launchd] 无法定位用户 LaunchAgents 目录,跳过 launchd 域操作:${(err && err.message) || err}\n`);
return false;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · high
在 managesRealAgent 函数中,确保 os.homedir() 的异常被完全捕获,并提供更详细的错误信息,以帮助用户定位问题。

Suggestion:

Suggested change
try {
real = realPlistPath();
} catch (err) {
process.stderr.write(`[launchd] 无法定位用户 LaunchAgents 目录,跳过 launchd 域操作:${(err && err.message) || err}\n`);
return false;
}
try {
real = realPlistPath();
} catch (err) {
const errorMessage = `[launchd] Failed to locate user LaunchAgents directory: ${err.message || err}`;
process.stderr.write(`${errorMessage}\n`);
console.error(errorMessage); // 确保错误信息被记录到日志中
return false;
}

Comment on lines +286 to +288
// 返回:{ path, exists, loaded, queried, state }。
// loaded 的含义**始终**是「launchd 报告该作业已加载」;重定向(沙箱)时不去查询,
// 于是 loaded 恒为 false,靠 queried=false 表示「没查过」而不是「查过、没加载」。

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

documentation · low
在 status 函数的文档注释中,明确说明 queried 字段的含义和用途,以帮助调用方正确理解其含义。

Suggestion:

Suggested change
// 返回:{ path, exists, loaded, queried, state }。
// loaded 的含义**始终**是「launchd 报告该作业已加载」;重定向(沙箱)时不去查询,
// 于是 loaded 恒为 false,靠 queried=false 表示「没查过」而不是「查过、没加载」。
// 返回:{ path, exists, loaded, queried, state }。
// - loaded: launchd 报告该作业是否已加载。
// - queried: 是否尝试查询 launchd 域。在沙箱场景下,queried=false 表示未查询;在非沙箱场景下,queried=true 表示已查询。
// 这有助于调用方区分「未查询」(沙箱场景)和「查询但未加载」的状态。

OCR 复审(PR #10)逐条核对后的处置:
- ensureLoaded/install/uninstall 补 skipped 契约说明:skipped 只表示
  「没去碰 launchd 域」,与「碰了但作业未加载」是两回事,调用方据此
  决定是否自行拉起进程。
- canonicalPath 补注 realpathSync 的 TOCTOU 兜底方向:目录消失时退回
  path.resolve,沙箱仍判为非真实作业(宁可不操作,也不误打真实作业)。
- 新增回归测试锁死该兜底方向,并在沙箱 uninstall 断言 skipped=true。

其余 OCR 意见经实测判为误报或不适用,未采纳:
- status() 缺 queried 文档:HEAD 已有该注释块(launchd.cjs:286-289)。
- uid() 未防 process.getuid 缺失:已有 typeof 检查 + try/catch。
- ensureLoaded 的 bootout 未捕获异常:已包 try/catch。
- 要求加 console.debug/console.warn:全项目零处使用 console.debug,
  且 tools/companion 的日志走 LOG_FILE 而非 stderr,属风格性建议。
- 要求异步 realpath + 重试:判定是同步短路径,重试反而扩大 TOCTOU 窗口。
- 要求校验 macOS 模拟器/真实硬件:无此可编程信号,非 darwin 已短路。
- 要求 homedir 错误信息更详细:现有信息不含路径,改详细反而更易泄露。
Comment on lines +136 to +142
function canonicalPath(p) {
const dir = path.dirname(p);
const base = path.basename(p);
try { return path.join(fs.realpathSync(dir), base); } catch (_) { /* 父目录不存在 */ }
try { return fs.realpathSync(p); } catch (_) { /* 文件也不存在 */ }
return path.resolve(p);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

performance · medium
canonicalPath 函数的注释提到 TOCTOU(Time-of-check to Time-of-use)风险,但未采取额外的同步机制(如文件锁或重试机制)来缓解风险。在高并发或快速变化的文件系统环境下,可能导致路径判断不准确。建议考虑引入文件锁或重试机制,以提高路径判断的可靠性。

function canonicalPath(p) {
const dir = path.dirname(p);
const base = path.basename(p);
try { return path.join(fs.realpathSync(dir), base); } catch (_) { /* 父目录不存在 */ }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · low
canonicalPath 函数在处理 fs.realpathSync 异常时使用了空 catch 块,未记录或处理异常信息。虽然这种方式简化了代码,但可能影响调试和问题排查。建议考虑记录异常信息,以便更好地追踪和处理此类问题。

const dir = path.dirname(p);
const base = path.basename(p);
try { return path.join(fs.realpathSync(dir), base); } catch (_) { /* 父目录不存在 */ }
try { return fs.realpathSync(p); } catch (_) { /* 文件也不存在 */ }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · medium
在 canonicalPath 函数中,fs.realpathSync 和 path.resolve 的异常处理使用了空 catch 块,这会导致调试和问题排查变得困难。建议在异常处理时记录日志或错误信息,以便更好地追踪问题。例如,可以使用 console.warn 或其他日志系统来记录异常信息。

Suggestion:

Suggested change
try { return fs.realpathSync(p); } catch (_) { /* 文件也不存在 */ }
try {
return fs.realpathSync(p);
} catch (err) {
console.warn(`[launchd] 路径解析失败,使用备用方案:${err.message}`);
return path.resolve(p);
}

const base = path.basename(p);
try { return path.join(fs.realpathSync(dir), base); } catch (_) { /* 父目录不存在 */ }
try { return fs.realpathSync(p); } catch (_) { /* 文件也不存在 */ }
return path.resolve(p);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security · high
在 canonicalPath 函数中,虽然使用了 fs.realpathSync 来处理符号链接,但当其抛出异常时直接退回 path.resolve。然而,path.resolve 在处理 macOS 上的特殊符号链接(如 /var 指向 /private/var)时,可能仍然无法完全避免误判。建议进一步验证 path.resolve 的行为,确保在所有情况下都能正确识别真实路径,避免误操作用户真实作业。

Comment on lines +176 to +178
function canTouchLaunchd(p) {
return process.platform === 'darwin' && uid() !== null && managesRealAgent(p);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security · high
uid() 函数在非 darwin 平台或特定环境下可能返回 null 或异常值,而 canTouchLaunchd 函数依赖该返回值进行判断。虽然当前实现已经处理了 uid() 返回 null 的情况,但建议进一步明确 uid() 的异常处理逻辑,并确保在所有平台下都能正确返回有效的用户 ID 或提供明确的错误信息。

let detail = '';
if (uid() !== null) {
if (canTouchLaunchd(p)) {
queried = true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

documentation · medium
status 函数新增了 queried 字段,但缺乏明确的说明,可能导致调用方误解 loaded 和 queried 的关系。建议在函数注释中明确说明 queried 的含义,以及如何与 loaded 结合使用,以避免调用方误解状态查询的结果。

@@ -115,6 +115,68 @@ function launchctl(args) {
return execFileSync('/bin/launchctl', args, { stdio: 'pipe', timeout: 20000, encoding: 'utf8' });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · medium
硬编码路径:launchctl 函数硬编码调用 /bin/launchctl,未考虑 macOS 版本差异或自定义路径。建议支持动态查找路径或配置自定义路径。

Comment on lines +136 to +142
function canonicalPath(p) {
const dir = path.dirname(p);
const base = path.basename(p);
try { return path.join(fs.realpathSync(dir), base); } catch (_) { /* 父目录不存在 */ }
try { return fs.realpathSync(p); } catch (_) { /* 文件也不存在 */ }
return path.resolve(p);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · medium
在 canonicalPath 函数中,虽然已经考虑了 TOCTOU 的风险,但建议进一步明确文档说明其安全性设计。例如,可以在注释中明确说明为什么选择不重试,而是直接退回到 path.resolve,以及这种设计的安全性保证。这有助于后续维护者理解其意图和安全性。

Comment on lines +210 to +211
// 返回契约(三种形状,靠 skipped / warn 区分「跳过」与「失败」):
// { loaded:true, reloaded:boolean } —— 已加载(reloaded 表示是否重载过)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · medium
变量命名不一致:ensureLoaded 返回的 reloaded 字段未在注释中明确说明其含义。建议在函数注释中明确说明 reloaded 表示是否重新加载了作业(即是否触发了 launchd 的重新加载流程)。

// 却报 loaded=true)——装了 agent 的开发机上这条断言必红。
// 返回 loaded=false + queried=false,让「没查过」与「查过、没加载」可区分。
let loaded = false;
let queried = false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · medium
status() 的 queried 字段命名不精确:queried 表示“目标非真实作业时不查询”,但命名不够明确。建议更改为 isRealAgentQueried 或 queriedLaunchd,以避免歧义。

OCR 第三轮复审(PR #10,run 37413811219)后补充的文档:
- install() 补完整返回契约(ok/reason、skipped 与 warn 互斥地表达
  「故意跳过」与「尝试后失败」、loaded/changed 的含义)。
- launchctl() 说明为何固定 /bin/launchctl:系统自带、不随版本漂移,
  且刻意不走 PATH——PATH 上的同名文件可能被劫持,而该调用会 bootout
  用户真实的自启作业。

第三轮其余意见与第二轮重复,仍判为误报或不适用:
- status() 缺 queried 文档 / ensureLoaded 缺 reloaded 文档:注释块已存在
  (launchd.cjs:211 与 :297-300)。
- realpath catch 是空块:两个 catch 均带行内原因注释,且已补 TOCTOU
  兜底方向说明;不加 console.warn 是因为全项目零处使用 console.* 写日志。
- 要求加文件锁/重试缓解 TOCTOU:判定是同步短路径,重试反而扩大窗口。
- 要求重命名 queried 为 isRealAgentQueried:会破坏已发布的返回契约,
  且 queried 已在调用方注释中定义。
- 要求动态查找 launchctl 路径:见上,固定绝对路径才是安全方向。
Comment on lines +143 to +144
try { return fs.realpathSync(p); } catch (_) { /* 文件也不存在 */ }
return path.resolve(p);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security · medium
canonicalPath 函数在 fs.realpathSync 失败后直接回退到 path.resolve,但未验证回退后的路径是否仍满足安全要求。例如,沙箱目录被删除时,path.resolve 可能返回不安全路径,导致 managesRealAgent 误判。建议在回退到 path.resolve 时,增加额外的安全验证,确保路径仍符合预期的安全策略。

Suggestion:

Suggested change
try { return fs.realpathSync(p); } catch (_) { /* 文件也不存在 */ }
return path.resolve(p);
try {
return fs.realpathSync(p);
} catch (_) {
const resolvedPath = path.resolve(p);
// 添加额外的安全验证逻辑,确保 resolvedPath 符合安全策略
return resolvedPath;
}

Comment on lines +153 to +159
let real;
try {
real = realPlistPath();
} catch (err) {
process.stderr.write(`[launchd] 无法定位用户 LaunchAgents 目录,跳过 launchd 域操作:${(err && err.message) || err}\n`);
return false;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security · high
managesRealAgent 函数在 os.homedir() 抛出异常时虽然写入了 stderr 日志,但未阻止后续 canonicalPath 的调用。如果 os.homedir() 失败,后续路径比较逻辑可能基于无效路径,导致安全判断错误。建议在 os.homedir() 失败时立即返回,避免后续逻辑执行。

Suggestion:

Suggested change
let real;
try {
real = realPlistPath();
} catch (err) {
process.stderr.write(`[launchd] 无法定位用户 LaunchAgents 目录,跳过 launchd 域操作:${(err && err.message) || err}\n`);
return false;
}
let real;
try {
real = realPlistPath();
} catch (err) {
process.stderr.write(`[launchd] 无法定位用户 LaunchAgents 目录,跳过 launchd 域操作:${(err && err.message) || err}\n`);
return false;
}
// 确保 realPlistPath() 成功后才进行后续比较逻辑
if (!real) {
return false;
}

Comment on lines +223 to +226
if (!canTouchLaunchd(plistFile)) {
// 只落盘,由调用方决定是否需要别的加载方式;不谎报「已加载」。
return { loaded: false, skipped: true, warn: '目标不是用户真实 LaunchAgent,跳过 launchd 加载(避免影响真实自启作业)' };
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security · medium
ensureLoaded 函数在 canTouchLaunchd 返回 false 时仅设置 skipped: true,但未明确阻止后续 launchctl 调用。如果调用方未正确检查 skipped 状态,仍可能触发 launchctl bootstrap 操作,从而误操作用户真实作业。建议在 ensureLoaded 函数中明确阻止任何 launchctl 操作,并确保调用方严格遵循返回值契约。

Suggestion:

Suggested change
if (!canTouchLaunchd(plistFile)) {
// 只落盘,由调用方决定是否需要别的加载方式;不谎报「已加载」。
return { loaded: false, skipped: true, warn: '目标不是用户真实 LaunchAgent,跳过 launchd 加载(避免影响真实自启作业)' };
}
if (!canTouchLaunchd(plistFile)) {
// 只落盘,由调用方决定是否需要别的加载方式;不谎报「已加载」。
// 确保调用方严格检查 skipped 状态,避免误操作 launchctl
return { loaded: false, skipped: true, warn: '目标不是用户真实 LaunchAgent,跳过 launchd 加载(避免影响真实自启作业)」 };
}
// 确保只有在 canTouchLaunchd 返回 true 时才执行 launchctl 操作
const loaded = isLoaded(l);

Comment on lines 244 to 245
function install(opts) {
const o = opts || {};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style · low
在 install、uninstall 和 status 函数中,使用 o 作为参数对象的别名不符合代码规范。建议使用更具语义的名称(如 options 或 config)以提高代码的可读性。例如,可以将 const o = opts || {} 改为 const options = opts || {}。

// ok:true + loaded:false + warn —— 碰了域但 bootstrap 失败
// skipped 与 warn 互斥地表达「故意跳过」与「尝试后失败」,调用方据此决定是否自行拉起进程。
function install(opts) {
const o = opts || {};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style · low
在 install、uninstall 和 status 函数中,使用 o 作为参数对象的别名不符合代码规范。建议使用更具语义的名称(如 opts 或 options)以提高代码的可读性和可维护性。

Suggestion:

Suggested change
const o = opts || {};
const opts = opts || {};

Comment on lines 220 to +223
function ensureLoaded(plistFile, changed, label) {
const l = label || LABEL;
if (uid() === null) return { loaded: false, warn: '无法获取 uid,跳过加载' };
if (!canTouchLaunchd(plistFile)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style · low
变量命名在函数参数和局部变量中存在不一致的情况。例如,ensureLoaded 函数接收 plistFile 参数,但在内部使用 p = plistPath(dir)。建议统一变量命名,提高代码可读性。例如,可以将 plistFile 参数统一命名为 p,或者在函数内部使用更具描述性的变量名。

Comment on lines 269 to +271
let loaded = false;
let warn = '';
let skipped = false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · medium
在 install 函数中,skipped 变量的初始化位置与其后续使用存在不一致。skipped 在 ensureLoaded 调用后才被赋值,但未在函数开头声明。如果 ensureLoaded 返回的对象中不包含 skipped 字段,将导致 skipped 变量未定义。建议在函数开头声明 skipped 变量,并确保其始终被正确赋值。

Suggestion:

Suggested change
let loaded = false;
let warn = '';
let skipped = false;
let loaded = false;
let warn = '';
let skipped = false;

Comment on lines 292 to +293
let bootedOut = false;
if (uid() !== null) {
let skipped = false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · medium
在 uninstall 函数中,skipped 变量的初始化位置与其后续使用存在不一致。skipped 在 canTouchLaunchd(p) 条件块内被赋值,但未在函数开头声明。如果 canTouchLaunchd(p) 返回 false,skipped 将保持未定义,导致后续返回的对象中可能包含未定义的字段。建议在函数开头声明 skipped 变量,并确保其始终被正确赋值。

Suggestion:

Suggested change
let bootedOut = false;
if (uid() !== null) {
let skipped = false;
let bootedOut = false;
let skipped = false;

if (uid() !== null) {
let skipped = false;
if (canTouchLaunchd(p)) {
try { launchctl(['bootout', `gui/${uid()}/${LABEL}`]); bootedOut = true; } catch (_) { /* 未加载:忽略 */ }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · medium
launchctl 的调用在多个位置存在,但错误处理方式不一致。例如,uninstall 和 status 函数中的 launchctl 调用均静默吞掉错误,而 ensureLoaded 函数中则返回 warn。建议统一错误处理策略,并考虑将 launchctl 调用封装为一个工具函数,以便集中管理超时、重试和错误日志输出逻辑。

Comment on lines 318 to +319
let loaded = false;
let queried = false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · medium
在 status 函数中,queried 变量的初始化位置与其后续使用存在不一致。queried 在 canTouchLaunchd(p) 条件块内被赋值,但未在函数开头声明。如果 canTouchLaunchd(p) 返回 false,queried 将保持未定义,导致后续返回的对象中可能包含未定义的字段。建议在函数开头声明 queried 变量,并确保其始终被正确赋值。

Suggestion:

Suggested change
let loaded = false;
let queried = false;
let loaded = false;
let queried = false;

OCR 第四轮复审(PR #10)里唯一成立的一条:ensureLoaded 在 uid()===null
时返回 {loaded:false, warn} 而**没有** skipped:true,与沙箱分支的契约
不一致。调用方按 skipped 判断「是故意跳过还是尝试后失败」,缺这个字段
会把「拿不到 uid、根本没去加载」误当成「加载失败」,进而重复重试。

同时修正上一轮我自己写错的文档:install() 契约里写「skipped 与 warn 互斥」,
实测沙箱分支两个字段同时出现(skipped=true 且 warn 有值)。改为
「skipped=true 时 warn 说明跳过原因,两者会同时出现」。

新增回归测试:删掉 process.getuid 后 install --now 必须
skipped=true、warn 含 uid、且一次 launchctl 都不调。已验证该测试在
修复前的源码上会红(fail 1),修复后转绿。

第四轮其余意见仍判为误报(逐条实测):
- canonicalPath 回退 path.resolve「未做安全校验」:兜底方向本就是安全的,
  沙箱目录消失时判为「非真实作业」→ 不碰 launchd 域,已在注释中写明。
- managesRealAgent 里 homedir 抛错后「未阻止后续比较」:catch 内已
  return false(launchd.cjs:154-158)。
- ensureLoaded 的 skipped「未阻止 launchctl」:该分支在任何 launchctl
  调用之前直接 return(launchd.cjs:223-227)。
- skipped/queried/bootedOut「未在函数开头声明」:均已声明
  (launchd.cjs:269-271、:292-293、:318-319)。
- 变量命名 o/plistFile/p:const o = opts || {} 是仓库既有约定(10 处)。
- status 缺 queried 文档:注释块已存在(launchd.cjs:314-319)。
- logPath 的 homedir 未处理:属未改动的既有代码,不在本次改动范围。
- launchctl 错误处理不一致:uninstall/status 的静默是设计意图(清理动作
  失败不应阻断卸载/查询),ensureLoaded 才需要把失败报给调用方。

// launchctl 固定在 /bin/launchctl:这是 macOS 系统自带、不随版本漂移的绝对路径,
// 刻意不走 PATH 查找——PATH 上的同名文件可能被用户环境劫持,而这里的调用会
// bootout 用户真实的自启作业。

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style · low
在代码中多处使用了非标准拼音(如 bootout),这可能影响国际化维护。建议统一使用英文术语(如 bootout 可以替换为 unload 或保持英文 bootout),以提高代码的可读性和一致性。

Comment on lines 117 to 119
function launchctl(args) {
return execFileSync('/bin/launchctl', args, { stdio: 'pipe', timeout: 20000, encoding: 'utf8' });
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

performance · low
在 launchctl 函数中,虽然设置了超时(20000ms),但建议考虑是否需要根据不同的操作类型(如 print、bootout、bootstrap)调整超时时间。例如,bootstrap 操作可能需要更长的时间,而 print 或 bootout 操作可能可以接受更短的超时。这有助于提高响应速度并避免不必要的等待。

Suggestion:

Suggested change
function launchctl(args) {
return execFileSync('/bin/launchctl', args, { stdio: 'pipe', timeout: 20000, encoding: 'utf8' });
}
function launchctl(args) {
// 根据操作类型动态调整超时时间,例如:
// - print/bootout: 5000ms
// - bootstrap: 30000ms
const timeout = args.includes('bootstrap') ? 30000 : 5000;
return execFileSync('/bin/launchctl', args, { stdio: 'pipe', timeout, encoding: 'utf8' });
}

// 刻意不走 PATH 查找——PATH 上的同名文件可能被用户环境劫持,而这里的调用会
// bootout 用户真实的自启作业。
function launchctl(args) {
return execFileSync('/bin/launchctl', args, { stdio: 'pipe', timeout: 20000, encoding: 'utf8' });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

performance · medium
在 launchctl 函数中,虽然设置了超时(20000ms),但建议考虑是否需要根据不同的操作类型(如 print、bootout、bootstrap)调整超时时间。例如,bootstrap 操作可能需要更长的时间。

Comment on lines 206 to +209
// 幂等加载:已加载且 plist 未变 → 什么都不做(**不能无条件 bootout+bootstrap**,
// 那会把正在跑的实例杀掉再拉起,用户每次点图标都会闪断一次)。
// plist 变了 → 必须重载,否则 launchd 仍按旧配置跑(升级后不生效)。
//

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

documentation · medium
在 ensureLoaded、install、uninstall 和 status 函数中,虽然返回契约已经明确,但建议在每个函数开头添加更详细的文档注释,以便开发者快速理解各个字段的含义及其区别。例如:

  • skipped:表示是否跳过 launchd 操作(沙箱/非 darwin/无 uid)。
  • warn:跳过或失败的原因说明。
  • loaded:是否已交给 launchd 加载。
    这有助于提高代码的可维护性和理解性。

Suggestion:

Suggested change
// 幂等加载:已加载且 plist 未变 → 什么都不做(**不能无条件 bootout+bootstrap**,
// 那会把正在跑的实例杀掉再拉起,用户每次点图标都会闪断一次)。
// plist 变了 → 必须重载,否则 launchd 仍按旧配置跑(升级后不生效)。
//
/**
* 幂等加载:已加载且 plist 未变 → 什么都不做(不能无条件 bootout+bootstrap,
* 那会把正在跑的实例杀掉再拉起,用户每次点图标都会闪断一次)。
*
* 返回契约:
* - { loaded: true, reloaded: boolean }:已加载(reloaded 表示是否重载过)。
* - { loaded: false, skipped: true, warn }:故意跳过域操作(沙箱/非 darwin/无 uid)。
* - { loaded: false, warn }:真失败(bootstrap 抛错)。
*/

// 注意 plistFile 指向沙箱时**绝不能**走 launchd 域(见 canTouchLaunchd):
// 那里的 bootout 打的是真实 LABEL,会把用户真实的 agent 打死并卸载。
//
// 返回契约(三种形状,靠 skipped / warn 区分「跳过」与「失败」):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

documentation · medium
在 ensureLoaded、install、uninstall 和 status 函数中,虽然返回契约已经明确,但建议在函数开头或文件顶部添加更详细的文档注释,以便开发者快速理解各个字段的含义及其区别。例如,skipped、warn、loaded 的具体含义和使用场景。

}
}
return { ok: true, path: written.path, changed: written.changed, loaded };
return { ok: true, path: written.path, changed: written.changed, loaded, skipped };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · high
在 install 函数中,返回值中的 skipped 字段未被正确处理。调用方代码需要解构并检查 skipped 字段,以确保在沙箱环境或非 darwin 平台下正确处理跳过逻辑。建议更新调用方代码以适配新的返回契约。

let removed = false;
try { fs.unlinkSync(p); removed = true; } catch (_) { /* 不存在:忽略 */ }
return { ok: true, path: p, removed, bootedOut };
return { ok: true, path: p, removed, bootedOut, skipped };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · high
在 uninstall 函数中,返回值中的 skipped 字段未被正确处理。调用方代码需要解构并检查 skipped 字段,以确保在沙箱环境或非 darwin 平台下正确处理跳过逻辑。建议更新调用方代码以适配新的返回契约。

}
const state = /state = (\w+)/.exec(detail);
return { path: p, exists, loaded, state: state ? state[1] : '' };
return { path: p, exists, loaded, queried, state: state ? state[1] : '' };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · high
在 status 函数中,返回值中的 queried 字段未被正确处理。调用方代码需要解构并检查 queried 字段,以确保在沙箱环境或非 darwin 平台下正确区分「未查询」和「查询但未加载」的情况。建议更新调用方代码以适配新的返回契约。

OCR 第五轮复审(PR #10)里唯一有实质的一条:新增的 skipped/queried
返回字段没有任何调用方消费。CLI 是唯一的人类可读出口,补上:
- uninstall 在 skipped=true 时打印「(未操作 launchd 域)」——否则
  「已移除/文件不存在」只描述 plist 文件,容易被当成真实作业也被卸载了。
- status 在 queried=false 时打印 queried=否——否则只打 loaded=否 会让
  packagers.cjs:213 / install-templates.cjs:184 的
  把「没查过」误读成「作业没在跑」。

刻意不改  这个 grep 契约:它只在真实 plist 路径上被调用
(无 dir 参数),此时 queried 恒为 true,行为与改动前一致;已实测
真机 status 输出不含 queried=否、且 grep 仍命中。

第五轮其余意见判为误报或不适用:
- 「bootout 是拼音、建议国际化」:bootout 是 launchctl 的真实子命令名,
  改名会让命令失效。
- 「按操作类型调超时」:print/bootout/bootstrap 共用 20s 是既有设计,
  本次未改动该行。
- 「函数开头补更详细文档」:四个函数的返回契约注释块均已存在。
- 「launchctl 固定路径/变量命名」:与第四轮重复,已逐条答复。
Comment on lines 319 to +323
let loaded = false;
let queried = false;
let detail = '';
if (uid() !== null) {
if (canTouchLaunchd(p)) {
queried = true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · medium
在 status 函数中,queried 变量的初始化和赋值逻辑与 install 和 uninstall 函数中的 skipped 变量类似,但缺少明确的初始化。如果 canTouchLaunchd(p) 返回 false,queried 变量将保持 undefined,这可能导致调用方无法正确理解返回值。

建议明确初始化 queried 变量为 false,并确保其在所有情况下都有明确的值。例如:

let queried = false;
let loaded = false;
let detail = '';
if (canTouchLaunchd(p)) {
  queried = true;
  try {
    detail = launchctl(['print', `gui/${uid()}/${LABEL}`]);
    loaded = true;
  } catch (_) { /* 未加载 */ }
}

Suggestion:

Suggested change
let loaded = false;
let queried = false;
let detail = '';
if (uid() !== null) {
if (canTouchLaunchd(p)) {
queried = true;
let queried = false;
let loaded = false;
let detail = '';
if (canTouchLaunchd(p)) {
queried = true;

// 显式判断 platform/uid 而不是靠 execFileSync 抛 ENOENT 再吞掉——「静默吞掉」
// 正是本 bug 在 CI 上无法暴露的原因。
function canTouchLaunchd(p) {
return process.platform === 'darwin' && uid() !== null && managesRealAgent(p);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · high
在 canTouchLaunchd 函数中,虽然已经明确判断了 process.platform === 'darwin',但非 darwin 平台的错误处理逻辑与 macOS 平台不一致。建议在非 darwin 平台下也返回 { loaded: false, skipped: true, warn } 格式,以保持一致性。

Suggestion:

Suggested change
return process.platform === 'darwin' && uid() !== null && managesRealAgent(p);
if (process.platform !== 'darwin') {
return { loaded: false, skipped: true, warn: '非 darwin 平台,跳过 launchd 加载' };
}
return uid() !== null && managesRealAgent(p);

@Seller-1990

Copy link
Copy Markdown
Owner Author

OCR 复审收敛情况(6 轮)

本 PR 共经历 6 轮自动 OCR 复审,结论逐轮收敛:

轮次 提交 意见数 实际采纳
1 773134d 9 2(realpath 判定、跳过分支回归测试)
2 6dc5268 16 0(误报/重复)
3 d80fae8 21 0(误报/重复)
4 4da8eb2 13 1(uid 取不到时缺 skipped)
5 f951da1 8 1(CLI 未输出 skipped/queried)
6 91c834a 2 0(误报/重复)

第 6 轮的两条经实测均为误报:

  1. status 的 queried 未初始化 —— 已初始化,见 tools/companion/launchd.cjs:319(let queried = false;)。
    实测:沙箱 queried=false(boolean)、真机 queried=true(boolean),不存在 undefined。
  2. canTouchLaunchd 应返回 {loaded,skipped,warn} —— 它是布尔谓词,只被 if (canTouchLaunchd(...)) 消费,
    不是加载器;返回对象会让 ensureLoaded/uninstall/status 三处调用点全部失去意义。

复审已收敛(意见数 13 → 8 → 2,且最后两条为重复误报),继续跑只会消耗评审服务商的配额
(多轮遇到 429 Too many requests: maximum 25 requests in 5 minutes)。

修复内容

根因:uninstall() 用硬编码 LABEL 执行 launchctl bootout gui/<uid>/local.zcode.advisor,
无视调用方传入的 dir 重定向。bootout 会终止该作业的进程并把它从 launchd 卸载,
KeepAlive 监督随之消失 → 进程被杀且不会自动重启。

后果:在本机跑 npm test 会打死真实外挂(test/launchd.test.js 用临时 dir 调 uninstall),
用户看到的就是「动不动就连不上本机 controller(外挂未运行?):Failed to fetch」。
CI 跑在 ubuntu-latest 上,/bin/launchctl 不存在、ENOENT 被 catch 吞掉,所以结构性看不见。

修复:新增 canTouchLaunchd(p)(darwin + 有 uid + 目标就是用户真实那份 plist),
uninstall/status/ensureLoaded 统一走该判定;沙箱调用只落盘/只删文件,绝不碰 launchd 域。

验证

  • 修复前:真机 npm test → 外挂进程被杀(pid 80069 → DEAD),生产日志 +3 行。
  • 修复后:外挂存活(pid 27737 → 27737),生产日志 delta 0;重复 3 次一致。
  • 全量测试 542 项 / 541 通过 / 0 失败 / 1 跳过(buildDmg 非 macOS 跳过,既有)。
  • 新增回归测试已验证在修复前的源码上会红。
  • tools/companion/launchd.cjs 与 plugins/zcode-advisor/ 副本无漂移。

@Seller-1990
Seller-1990 merged commit 82e5426 into main Oct 6, 2026
1 of 2 checks passed
@Seller-1990
Seller-1990 deleted the fix/launchd-uninstall-isolation branch October 6, 2026 17:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant