fix(desktop): enable microphone access on macOS - #8715
Conversation
macOS Desktop validation reportTested the official Desktop v0.1.0 arm64 DMG in an isolated home/workspace and the locally rebuilt fixed bundle.
Automated checks also passed: Desktop Rust tests (23/23), release helper tests, debug app bundle, plist validation, Prettier, actionlint, repository build/typecheck, and focused SSE, WebSocket, and voice tests. |
|
Thanks for the PR! Template looks good ✓ Problem: observed, not theoretical — the shipped Desktop v0.1.0 DMG was inspected and lacks both Direction: aligned — completes the permission chain for the already-shipped Desktop voice dictation feature (part of #8092). No new surface, nothing speculative. Size: not applicable — 90 changed lines (86 added) across one release workflow, one release-test script, and three plist files; no core paths touched. Approach: scope still feels right after the two follow-up commits. Since the first pass, the PR gained a helper-entitlement narrowing (ripgrep now signs without app entitlements; the bundled Node runtime keeps only Risk: no elevated risk signals (no high-risk path matches). Moving on to code review. 🔍 中文说明感谢贡献! 模板完整 ✓ 问题:已观测到,不是理论问题——已发布的 Desktop v0.1.0 DMG 经检查缺少 方向:对齐——补全已上线的 Desktop 语音听写功能的权限链路(#8092 的一部分)。没有新增面,也没有投机性内容。 规模:不适用——共 90 行改动(86 行新增),涉及一个发布 workflow、一个发布测试脚本和三个 plist 文件;不涉及核心路径。 方案:两个后续 commit 之后范围依然合理。首轮审查后,PR 新增了 helper entitlement 收窄(ripgrep 签名不再携带应用 entitlement,内置 Node runtime 通过新的最小 plist 仅保留 风险:无升级风险信号(未命中高风险路径)。 进入代码审查 🔍 — Qwen Code · qwen3.8-max Reviewed at |
Code reviewMy independent take before reading the diff: a packaged Tauri app needs (1) the usage-description key merged into the bundle What I verified against upstream Tauri sources (
No correctness, security, or convention issues found. Not verified: actual macOS bundling and signing — this review runs on Linux and doesn't execute PR code. The guard for that gap is the release-time assertion this PR itself adds. Neither CI evidenceThe PR's own CI on the reviewed commit, fetched via API (nothing re-run here). The two Desktop Shell jobs are the substantive ones: they run Final CI results for
One row per check name (latest run); skipped checks omitted; failures sort first. / 每个检查名一行(取最新一次运行),省略 skipped,失败项排在最前。 中文说明代码审查读 diff 之前我的独立思路:打包后的 Tauri 应用需要 (1) 把用途说明 key 合并进 bundle 的 对照上游 Tauri 源码(
未发现正确性、安全性或规范问题。 未验证:真实的 macOS 打包与签名——本审查运行在 Linux 上,且不执行 PR 代码。这个缺口的守卫正是 PR 自己新增的发布期断言。 CI 证据以上是通过 API 获取的该 commit 自身 CI 结果(未重跑任何东西)。两个 Desktop Shell 任务是实质性的:运行 — Qwen Code · qwen3.8-max Reviewed at |
|
Confidence: 4/5 — small, focused, and the mechanism is independently verified against upstream Tauri sources; the one reservation is that the new release-workflow assertions are only linted in PR CI and will first execute at release time (fail closed by design). Stepping back: this is exactly the kind of PR the gate should wave through. The problem is observed on a shipped artifact (the v0.1.0 DMG), the fix is the minimum viable set — two plist values plus assertions that make the regression class visible — and my independent proposal matches the diff one-for-one. No drive-by changes, no scope creep, no core paths. If I'm maintaining this in six months, the release-time checks are the part I'll be glad about: they turn "the signed app silently lacks an entitlement" from a user report into a failed release step. The residual uncertainty is inherent to the change: macOS signing behavior can't be exercised in Linux CI, and the author's local ad-hoc signing results remain their claim rather than evidence I can re-run. That's acceptable here because the PR's own assertions close the loop at the first real release — loudly, not silently. Verdict: approve. CI is still in flight on this commit (the ubuntu Test job), so approval is deferred until it lands green on 中文说明置信度:4/5 —— 改动小而聚焦,机制已对照上游 Tauri 源码独立验证;唯一的保留是新增的发布流程断言在 PR CI 里只经过 lint,要到发布时才真正执行(设计上 fail closed)。 整体来看:这正是应该放行的 PR。问题来自已发布产物(v0.1.0 DMG)的实际观测,修复是最小可行集——两个 plist 值加上让这类回归可见的断言——diff 与我的独立方案完全一致。没有顺手改动,没有范围蔓延,不涉及核心路径。半年后维护这段代码时,最值得庆幸的会是发布期检查:它把"签名后的应用悄悄缺少某个 entitlement"从用户报告变成一次失败的发布步骤。 剩余的不确定性是这类改动固有的:Linux CI 无法执行 macOS 签名行为,作者本地 ad-hoc 签名的结果仍是其自述而非可复跑的证据。这里可以接受,因为 PR 自带的断言会在首次真实发布时闭环——而且是响亮地失败,不是静默通过。 结论:批准。该 commit 的 CI 仍在运行(ubuntu Test 任务),批准将延后到 CI 在该 commit 上全绿时执行。 — Qwen Code · qwen3.8-max Reviewed at |
Code Coverage Summary
CLI Package - Full Text ReportCore Package - Full Text ReportFor detailed HTML reports, please see the 'coverage-reports-22.x-ubuntu-latest' artifact from the main CI run. |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship — CI landed green after the review. ✅
macOS packaged-app verificationVerified the current head Result
The local artifact used ad-hoc signing to reproduce the workflow's entitlement separation. Developer ID signing, notarization, and updater archive signing require release secrets and were not claimed here. Remaining manual gateI could not honestly mark the microphone permission flow itself as passed. The So this verifies that the required usage string and entitlement are present in the real packaged app and that the app runs, but a human still needs to dismiss the updater and confirm the first microphone request produces the macOS consent prompt and usable capture. No microphone/TCC screenshot is attached because that state was not reached. |
Follow-up: packaged WKWebView microphone E2E passThe remaining native capture gate is now exercised against a fresh local To expose the existing voice control in an isolated test home, I configured The visible I then switched the isolated configuration to an authorized Finally, I exercised one uninterrupted packaged-app path: the existing Voice button started WKWebView microphone capture, system speech played through the Mac speakers, the bundled daemon received PCM and finalized the real ASR request successfully, and the transcript was inserted into the prompt composer. Because macOS denied external click automation without Accessibility permission, the start/stop clicks were triggered by a one-shot test-only script injected into a copied app bundle under Both screenshots contain only the Qwen Code Desktop window (the first is a tighter crop of the composer); no full-screen capture was uploaded. This closes the local WKWebView microphone → bundled daemon → real ASR → composer-insert path. It does not claim Developer ID signing or a newly displayed TCC consent prompt; macOS reused the existing authorization for this local app identity. |
|
@qwen-code /triage |
|
Sandboxed verification: ✅ passed — merge-ready (agent verdict) - workflow run Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check. Scripted assertions: 79 passed · 0 failed · 79 total 中文 — 判定:✅ 通过 · 可合入(agent 判定)沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查。 脚本断言:79 通过 · 0 失败 · 79 总计 Verification reportVerification report — PR #8715
|
| cell | oracle | base | head |
|---|---|---|---|
test-release.js on own tree |
exit code | green | green |
head test-release.js on base tree |
expected red | — | red, ripgrep must not inherit the app entitlements |
base test-release.js on head tree |
expected red | red, old rg assertion | — |
| sign step, rg fixture | recorded codesign args | --entitlements src-tauri/Entitlements.plist |
no --entitlements, still --options runtime --timestamp |
| sign step, node fixture | recorded codesign args | full Entitlements.plist |
NodeEntitlements.plist |
| verify step on no-audio-input bundle (v0.1.0 shape) | step exit | 0 (blind) | non-zero |
| verify step on audio-input=true bundle | step exit | — | 0 |
| verify step on audio-input=false / empty dump | step exit | — | non-zero (fail-closed) |
| smoke step on Info.plist without mic key | step exit, npm call | 0, smoke ran (blind) | non-zero, smoke not called |
| smoke step on Info.plist with mic key | step exit, npm call | — | 0, smoke:packaged -- <exe> recorded |
Mutation matrix on the new test-release.js assertions (M1 rg-entitlements reverted, M2 node-entitlements reverted, M3 audio-input added to NodeEntitlements.plist, M4 rg warning removed): all four killed by their named assertion message; unmutated controls green on both arms.
Plist content deltas head vs base (parse + key/value assertions, 03-plist-validation.mjs, witness 03-plist-validation-head-vs-base.png): 20/20 — head Entitlements.plist = exactly {allow-jit, audio-input, network.client, network.server}; base lacks audio-input; NodeEntitlements.plist = exactly {allow-jit}, no mic/network keys; Info.plist = exactly one non-empty NSMicrophoneUsageDescription string, wording identical to the Electron app's (packages/desktop/apps/electron/electron-builder.yml); tauri.conf.json macOS bundle config byte-identical to base (the PR routes everything through the pre-existing entitlements key).
Repo gates on the changed surfaces (04-gates.mjs, witness 04-lint-gates-with-live-controls.png): 13/13 — actionlint 1.7.12 (repo flags) clean on head and base workflows, shellcheck clean on all six extracted step blocks (-s bash), prettier and eslint clean on the changed JS; every linter's live plant control caught its violation.
Helper-narrowing blast radius: runtime.rs spawns node with only QWEN_CODE_DESKTOP/QWEN_SERVER_TOKEN env; no DYLD* anywhere in desktop-shell. The only Hardened-Runtime-enforced entitlement node had on base was allow-jit (network.* are App-Sandbox entitlements, unenforced without com.apple.security.app-sandbox, which the app does not declare), and NodeEntitlements.plist keeps exactly allow-jit — the enforced surface is unchanged. Ripgrep is a plain Rust binary; losing allow-jit/network.* removes nothing it uses.
Reviewer Test Plan walkthrough:
| plan step | result here |
|---|---|
| Build macOS app, sign, inspect bundle | not executable on Linux; reproduced structurally: bundler source proves the merge, stub replay proves the gates |
| Metadata contains mic usage description | verified at config/source level (wiring above) + plist parse (03-plist-validation.mjs); not against a built bundle |
Signature contains audio-input = true |
verified at config/source level + replay of the new verify gate on fixture bundles |
| Existing packaged-app smoke still starts | smoke step logic replayed; new plist check runs before smoke and fails closed; the runtime-start half is unchanged by this PR and runs in the lane's macOS CI |
Findings (non-blocking)
F1 (nit). Verify macOS signature uses codesign -d --entitlements - --xml, while the repo's proven production pattern is codesign -d --entitlements :- (.github/workflows/live-host.yml:146). The --xml flag's existence could not be verified from this Linux container (codesign is not in Apple's open-source SecurityTool repo; no macOS available). Bounded: every failure mode is fail-closed — an unrecognized flag aborts the step under set -euo pipefail, a missing key yields test "" = true → red, and PlistBuddy parses both XML and binary plists so a format mismatch cannot silently pass. Worst case is a loud failure on the first signed release run, not a silent false green.
Suggested fix (unmeasured here — macOS-only tool)
- codesign -d --entitlements - --xml "$app" > "$entitlements" 2>/dev/null
+ codesign -d --entitlements :- "$app" > "$entitlements" 2>/dev/null(matches the live-host precedent; cannot be executed in this environment)
F2 (coverage gap, completeness report). test-release.js pins the signing-step changes (rg/node entitlements, ordering, warnings) but nothing pins the two new verification gates (Print :com.apple.security.device.audio-input in Verify macOS signature, Print :NSMicrophoneUsageDescription in the macOS smoke step): deleting those lines keeps the suite green (observation assertions in harness 01). The gates themselves are exercised by this round's replay; a future workflow edit could drop them without a red test. Not a merge condition.
Not covered
- Real macOS behavior: TCC prompt, WKWebView mic capture, actual
codesign/spctl/PlistBuddysemantics, and the--xmlflag question (F1). The container has no macOS toolchain; the replay substitutes stubs for those four tools (documented below), so it reproduces the gate logic, not the Apple-tool trigger path. Production-certificate signing was also not exercised by the author. yamllint(repo gate) not run: the container has nopip/ensurepip/venv. YAML parse validity is established instead by actionlint (which parses the full document) and the js-yaml extraction.- Per-commit attribution: checkout is depth-2 (only merge, base tip, PR head reachable); the three commits in the metadata were verified as the aggregate
HEAD^1..HEADdiff only. - The stub replay is uncalibrated: round 1 has no real release-run artifact to byte-compare against (the steps run only on macOS release runners). Calibration would be the log of the next real
desktop-releaserun. - Full
npm test -w-style gates for desktop-shell were not run (the package has no vitest suite; its release helper suite istest-release.js, which is the A/B subject above). Rustcargo testnot run (no Rust toolchain in container; the PR touches no Rust code).
Methodology
Environment: CI verify container (node:22-bookworm), merge-ref checkout (HEAD merge, HEAD^1 base d91c66119b, HEAD^2 head 97e0e30ae0), npm ci+npm run build pre-run at head. Harnesses live in this artifact dir (01..04-*.mjs) and drive the real repo files: 01 runs the real test-release.js in symlinked scratch trees (head/base × head/base + four mutations); 02 extracts the three changed run: blocks verbatim via js-yaml (only substitutions: ${{ matrix.rust_target }} → aarch64-apple-darwin, and /usr/libexec/PlistBuddy → PATH stub because the container user cannot write /usr/libexec; the stub reimplements Print :key exit/print semantics via python3 plistlib), then executes them under bash --noprofile --norc with stub codesign/spctl/npm against fixture bundles, asserting on recorded call logs and exit codes; 03 parses all three plists with plistlib and asserts key/value deltas head vs base; 04 runs the repo-pinned gates (actionlint 1.7.12 with scripts/lint.js flags, shellcheck, prettier, eslint), each with a planted-violation live control. Tauri wiring was proven from tauri-apps/tauri source at tag tauri-cli-v2.11.4 (exact pinned CLI version). Raw logs in logs/; captures 01-04*.png are the harness runs as printed.
Evidence images
Harness scripts and raw logs are in the workflow run artifacts (7-day retention).
— Qwen Code · sandboxed verification
Follow-up: second packaged-app voice passRepeated the packaged WKWebView voice flow with the same one-shot test-only start/stop hook described above. The audio fixture was played only after the bundled daemon logged This was captured by macOS window ID, so the image contains only the Qwen Code Desktop window and no desktop or other application content. |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Reviewed. Suggestions are inline. Not reviewed: reverse audit — stopped before round 3 by the review time budget. Not linted (tool limitation, not a blocker): the executable-script lint — .github/workflows/desktop-release.yml: actionlint embedded-shell source mapping is not yet supported — not linted.
中文说明
已审查。 建议见行内评论。 未审查:反向审计——评审时间预算不足,未能开始第 3 轮。 未检查(工具限制,非阻断):the executable-script lint — .github/workflows/desktop-release.yml: actionlint embedded-shell source mapping is not yet supported — not linted。
— qwen3.8-max via Qwen Code /review (v0.21.7)
doudouOUC
left a comment
There was a problem hiding this comment.
Approving — reviewed at 00eabce419. The change is correct and well-shaped; two things below need saying, one of which I confirmed with my own probe.
The shape is right
Getting mic access under the hardened runtime needs three things, and all three are here: the com.apple.security.device.audio-input entitlement on the app, an NSMicrophoneUsageDescription string (without which macOS denies the request outright), and — the part that is easy to miss — not handing that entitlement to the helper binaries.
The entitlement split is the best part of this PR. Previously the vendored rg and the Node runtime were both signed with the app's full Entitlements.plist; after this change ripgrep is signed with no entitlements at all (correct — a Rust search binary needs none) and Node gets a minimal NodeEntitlements.plist carrying only allow-jit. That means adding microphone to the app does not silently grant microphone to two extra executables.
I checked the one thing that could have made the Node narrowing a regression: Entitlements.plist has no com.apple.security.app-sandbox key, so the network.client / network.server entries in it are App Sandbox entitlements and inert here. Dropping them from the Node sidecar therefore costs it nothing — Node keeps the allow-jit it actually needs for V8.
Adding release-time assertions for both halves (dumping the signed app's entitlements and requiring audio-input, and requiring NSMicrophoneUsageDescription in the bundled Info.plist) is the right compensating control for a property that can only really be observed on a signed bundle.
R1-2 confirmed — the ripgrep guard pins the text, not the property
I reproduced this rather than relaying it. Re-adding --entitlements src-tauri/Entitlements.plist on its own continuation line between --sign "$APPLE_SIGNING_IDENTITY" and --options runtime — a perfectly valid codesign argument order — leaves all five of the PR's new assertions green:
PASS ripgrep must not inherit app entitlements (exact old form absent)
PASS ripgrep codesign continuation form present
PASS Node uses NodeEntitlements.plist
PASS Node must NOT receive microphone access
PASS ripgrep-missing warning retained
while the vendored rg is once again signed with the full app entitlement set, microphone included. assert.doesNotMatch(/--entitlements src-tauri\/Entitlements\.plist \{\} \+/) only catches the flag immediately before {} +, and the release-time entitlement dump inspects the .app bundle, not ripgrep — so nothing downstream catches it either.
This is a guard weakness, not a live defect: the workflow as written today signs ripgrep correctly. Suggestion severity is right. But it is worth fixing while the context is fresh, because the whole point of the split is that the next edit cannot quietly undo it.
R1-1 is also fair — only the negative half is pinned
The new PR-time assertions cover "Node must not have microphone" but none of the positive invariants. I checked all four by hand and they hold today: Entitlements.plist keeps audio-input, NodeEntitlements.plist keeps allow-jit, Info.plist carries a non-empty usage description, and both release-time assertion lines are present in the workflow. None of them is guarded, so reverting the audio-input hunk or deleting Info.plist would leave the suite green.
Info.plist deserves a specific mention: its only wiring into the bundle is Tauri's filename auto-discovery — nothing in the repo references the file. That is a legitimate Tauri convention, but it means a rename, a relocation, or a Tauri behaviour change would produce a silently mic-less build, caught only at release time.
Coverage — this is the part I would not let slide
CI never ran on this head. Not "the relevant job was skipped" — there are zero workflow runs for 00eabce419, and the branch is in-repo, not a fork. All 51 check-runs on the commit are skipped bot workflows; there is no Test, no Desktop Shell, no Classify PR. So the Desktop Shell job that runs test-release.js — the very guard this PR extends — has not executed against this change.
I ran those assertions myself to fill the gap: 10/10 pass (the PR's five, plus the five unguarded positive invariants above). What I could not verify, and no one can at PR time: that Tauri actually merges src-tauri/Info.plist into the built bundle, and that the entitlement lands on the signed binary. Both require a real macOS signed build; the new release-time assertions are the right place for them, and they only fire on workflow_dispatch.
Please re-trigger CI on this head before merging so the desktop leg actually runs.
中文说明
批准 —— 审查提交 00eabce419。改动正确且形态得当;下面两点需要说明,其中一点我用自己的探针复现了。
形态是对的:在 hardened runtime 下拿到麦克风需要三件事,本 PR 三件齐备——应用侧 com.apple.security.device.audio-input 权限、NSMicrophoneUsageDescription 文案(缺失时 macOS 会直接拒绝请求),以及容易被忽略的一点:不要把该权限一并交给辅助二进制。
权限拆分是本 PR 最好的部分。此前随附的 rg 与 Node 运行时都用应用完整的 Entitlements.plist 签名;改动后 ripgrep 完全不带权限签名(正确——Rust 搜索程序不需要),Node 改用只含 allow-jit 的最小 NodeEntitlements.plist。这意味着给应用加麦克风权限,不会顺带把麦克风授予另外两个可执行文件。
我核对了唯一可能让 Node 收窄变成回归的点:Entitlements.plist 中没有 com.apple.security.app-sandbox,因此其中的 network.client/network.server 属 App Sandbox 权限、在此为惰性。把它们从 Node 侧移除不产生任何代价——Node 保留了 V8 真正需要的 allow-jit。
为两侧都加发布期断言(导出已签名应用的权限并要求含 audio-input;要求打包后的 Info.plist 含 NSMicrophoneUsageDescription)是对"只能在已签名 bundle 上观察到的属性"的正确补偿控制。
R1-2 已确认——ripgrep 的守卫钉的是文本而非属性:我复现而非转述。把 --entitlements src-tauri/Entitlements.plist 重新加在 --sign 与 --options runtime 之间的独立续行上(codesign 完全合法的参数顺序),本 PR 新增的五条断言全部保持通过,而随附的 rg 又会以应用完整权限集(含麦克风)签名。assert.doesNotMatch(/--entitlements … \{\} \+/) 只能捕获紧邻 {} + 之前的那种写法,而发布期的权限导出只检查 .app bundle、不检查 ripgrep,因此下游也拦不住。
这是守卫弱点而非现行缺陷——今天的 workflow 对 ripgrep 的签名是正确的,Suggestion 的定级是恰当的。但趁上下文尚新修掉值得:拆分的全部意义就在于下一次改动不能悄悄把它撤销。
R1-1 也成立——只钉住了否定的一半:新增的 PR 期断言只覆盖"Node 不得有麦克风",四条正向不变式一条未钉。我逐条手工核对,今天都成立:Entitlements.plist 保留 audio-input、NodeEntitlements.plist 保留 allow-jit、Info.plist 文案非空、两条发布期断言均在 workflow 中。由于都无守卫,撤掉 audio-input 那个 hunk 或删掉 Info.plist,套件依然全绿。
Info.plist 值得单独一提:它接入 bundle 的唯一途径是 Tauri 按文件名自动发现——仓库里没有任何地方引用该文件。这是 Tauri 的正当约定,但也意味着一次改名、移动位置或 Tauri 行为变更,都会产出一个静默没有麦克风的构建,且只有到发布期才会被发现。
覆盖率——这一点我不会放过:CI 在本 head 上从未运行过。 不是"相关 job 被跳过",而是 00eabce419 的 workflow 运行数为零,且该分支在本仓库内、并非 fork。该提交上 51 个 check-run 全是被跳过的机器人工作流;没有 Test、没有 Desktop Shell、没有 Classify PR。因此运行 test-release.js 的 Desktop Shell job——也就是本 PR 所扩展的那个守卫——并未对本次改动执行过。
我自己跑了这些断言来填补空缺:10/10 通过(本 PR 的五条,加上上述五条无守卫的正向不变式)。我无法验证、且任何人在 PR 期都无法验证的是:Tauri 是否真的把 src-tauri/Info.plist 合并进构建产物,以及该权限是否真的落在已签名二进制上。两者都需要真实的 macOS 签名构建;新增的发布期断言正是它们该待的地方,而那只在 workflow_dispatch 时触发。
合并前请在本 head 上重新触发 CI,让 desktop 腿真正跑一次。
|
@qwen-code /triage |
|
Sandboxed verification: ✅ passed — merge-ready (agent verdict) - workflow run Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check. Scripted assertions: 78 passed · 0 failed · 78 total 中文 — 判定:✅ 通过 · 可合入(agent 判定)沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查。 脚本断言:78 通过 · 0 失败 · 78 总计 Verification reportVerification report — PR #8715
|
| # | finding | severity | status at head 00eabce |
|---|---|---|---|
| F1 | Verify macOS signature dumps entitlements with codesign -d --entitlements - --xml, while the repo's proven pattern is --entitlements :- (.github/workflows/live-host.yml:146) |
nit | stands — re-grepped at the new head: workflow still uses --xml, precedent still :-. Failure modes remain fail-closed (set -euo pipefail: unknown flag aborts the step; missing key → test "" = true → red; PlistBuddy parses XML and binary plists). Worst case unchanged: a loud failure on the first signed release run, never a silent false green. |
| F2 | nothing pins the two new workflow verification gates (Print :com.apple.security.device.audio-input in Verify macOS signature; Print :NSMicrophoneUsageDescription in the macOS smoke step) |
coverage gap | stands — re-measured, not diffed: mutants M5a (verify gate deleted) and M5b (smoke gate deleted) both keep test-release.js green (exit=0 each) at the new head, while positive controls M1–M4 are each killed by their named assertion, so the suite is proven capable of going red. Completeness report, not a merge condition. |
No round-1 measurement was carried forward on paper: every cell below was rebuilt and re-executed at head 00eabce / base 3037744. The Tauri wiring proof is the one static fact re-verified at its source (the pinned tag), after re-asserting its input closure locally — packages/desktop-shell/package-lock.json still pins @tauri-apps/cli@2.11.4 and the PR changes no dependency files (harness 03).
Delta since round 1
- Head
97e0e30→00eabce: the added commit isMerge branch 'main' into codex/8092-macos-validation. The effective diffHEAD^1..HEADis the same 5 files / +49 / −4 as round 1, and all round-1 content claims (plist key sets, assertion texts, workflow hunks, wording) re-assert true byte-for-byte at the new head (harnesses 01–03) — the merge only re-based the PR, it changed none of its content. No conflict markers in any changed file (clean merge). - Base
d91c661→3037744: the new tip isfix(integration-tests): make the project typecheckable and fix what that found (#8693). It does not touch any file in this PR's closure; base-side continuity is established empirically — cell B (base script × base tree) is green, the base-arm workflow cells replay exactly the pre-PR shape, and baseEntitlements.plistis precisely the pre-PR key set (harness 03). - Because
HEADis itself the clean merge of the PR into the current base tip, "verify the merge, not only the PR" holds by construction for this round. - Per-commit attribution:
git rev-list HEAD^1..HEAD^2returns 1 commit at this depth-2 shallow checkout while the metadata snapshot lists 4 — the shallow boundary is confirmed, so the aggregate diff is what was verified (see Not covered).
Central claim and A/B
Central claim: the packaged macOS Tauri app carries NSMicrophoneUsageDescription and a signed com.apple.security.device.audio-input = true, and release CI fails if either regresses. Secondary: helper binaries no longer inherit the app entitlements (ripgrep: none; node: minimal NodeEntitlements.plist).
Wiring (re-verified at tag tauri-cli-v2.11.4, the exact pinned CLI version):
crates/tauri-cli/src/interface/rust.rs(~1640–1670): on a macOS host the CLI buildsMacOsSettings.info_plistfromtauri_dir.join("Info.plist")when it exists (plus any explicitconfig.macos.info_plist), merged viamerge_plist;#[cfg(not(target_os = "macos"))] info_plist: Noneleaves Linux/Windows builds untouched. The merge is unconditional on macOS hosts — nobundle.macOS.infoPlistconfig needed.crates/tauri-bundler/src/bundle/macos/app.rs,create_info_plist(): after generating the default keys,settings.macos().info_plistoverrides key-by-key and the result is written toContents/Info.plist— soNSMicrophoneUsageDescriptionlands in the bundle.crates/tauri-bundler/src/bundle/macos/sign.rs,sign():settings.macos().entitlements(path form —tauri.conf.jsonuses the pre-existing"entitlements": "Entitlements.plist", byte-identical head vs base) is handed to the signer withis_an_executable && settings.macos().hardened_runtime— so the newaudio-inputkey reaches the app's codesign under hardened runtime.
A/B table (harnesses 01-test-release-ab.mjs, 02-workflow-replay.mjs; witnesses: 01-test-release-ab-head-vs-base.png, 02-workflow-replay-sign-verify-smoke.png):
| cell | oracle | base | head |
|---|---|---|---|
test-release.js on own tree |
exit code | green | green |
head test-release.js on base tree |
expected red | — | red, AssertionError: ripgrep must not inherit the app entitlements |
base test-release.js on head tree |
expected red | red, AssertionError: ripgrep codesign failures must fail the signing step |
— |
sign step, rg fixture (2 binaries, batched find -exec {} +) |
recorded codesign args | 2 rg paths with --entitlements src-tauri/Entitlements.plist |
2 rg paths, no --entitlements, --options runtime --timestamp kept |
| sign step, node fixture | recorded codesign args | full Entitlements.plist |
NodeEntitlements.plist |
| sign step, ripgrep dir missing | exit, stdout | — | 0, ::warning::Ripgrep vendor directory not found (fail-soft branch works) |
| verify step on v0.1.0-shape bundle (no audio-input) | step exit | 0 (blind) | non-zero |
| verify step on head-shape bundle (audio-input=true) | step exit, recorded args | — | 0; codesign -d --entitlements - --xml recorded |
| verify step on audio-input=false / empty dump | step exit | — | non-zero / non-zero (fail-closed both) |
| smoke step on Info.plist without mic key | exit, npm call | 0, smoke:packaged called (blind) |
non-zero, smoke:packaged not reached |
| smoke step on Info.plist with mic key | exit, npm call | — | 0, smoke:packaged -- <found executable> recorded |
Workflow blocks were extracted verbatim via js-yaml from head and base; a round-trip assertion proves the only substitutions are ${{ matrix.rust_target }} → aarch64-apple-darwin and /usr/libexec/PlistBuddy → PATH stub (container user uid 1000 cannot create /usr/libexec). Blocks ran under bash --noprofile --norc with each step's own set -euo pipefail. Stub semantics were control-checked first: the PlistBuddy stub (python3 plistlib) reproduces value-print / missing-key-red / unparseable-red before any cell used it. Replay is still uncalibrated (see Not covered).
Mutation matrix on the head tree (scratch worktree at HEAD; unmutated control green first; witness 01-test-release-ab-head-vs-base.png):
| mutant | suite result | classification |
|---|---|---|
M1 rg --entitlements line restored |
killed — ripgrep must not inherit the app entitlements |
pinned |
| M2 node entitlements path reverted | killed — Node.js must use its minimal helper entitlements |
pinned |
M3 audio-input added to NodeEntitlements.plist |
killed — Node.js must not receive microphone access |
pinned |
M4 rg-missing ::warning:: line removed |
killed — missing ripgrep binaries must be visible in release logs |
pinned |
| M5a verify gate (4 lines) deleted | survives (green) | coverage gap — F2 |
| M5b smoke gate (2 lines) deleted | survives (green) | coverage gap — F2 |
Positive control: M1–M4 prove the harness turns the suite red; the M5 survivors are therefore real unpinned axes, not harness blindness.
Plist content deltas (harness 03-plist-validation.mjs, witness 03-plist-validation-head-vs-base.png): head Entitlements.plist = exactly {allow-jit, audio-input, network.client, network.server}, all true; base = same minus audio-input; NodeEntitlements.plist = exactly {allow-jit}; Info.plist = exactly one non-empty NSMicrophoneUsageDescription, wording byte-identical to the Electron app's (packages/desktop/apps/electron/electron-builder.yml); tauri.conf.json bundle.macOS byte-identical head vs base; PR touches exactly the 5 expected files and no package*.json.
Helper-narrowing blast radius (carried analysis, inputs re-checked): tauri.conf.json and the runtime spawn path are unchanged by the PR; the only Hardened-Runtime-enforced entitlement node had on base was allow-jit (network.* are App-Sandbox entitlements, unenforced without com.apple.security.app-sandbox, which the app does not declare), and NodeEntitlements.plist keeps exactly allow-jit — the enforced surface is unchanged. Ripgrep is a plain Rust binary; losing allow-jit/network.* removes nothing it uses.
Reviewer Test Plan walkthrough:
| plan step | result here |
|---|---|
| Build macOS app, sign, inspect bundle | not executable on Linux; reproduced structurally: pinned-tag bundler source proves the merge (above), stub replay proves the gates |
| Metadata contains mic usage description | config/source level + plist parse (harness 03); not against a built bundle |
Signature contains audio-input = true |
config/source level + replay of the new verify gate on fixture bundles |
| Existing packaged-app smoke still starts | smoke step logic replayed; the new plist check runs before smoke:packaged and fails closed; the runtime-start half is unchanged by this PR and runs in the lane's macOS CI |
Gates (harness 04-gates.mjs, witness 04-lint-gates-with-live-controls.png)
- actionlint 1.7.12 with the repo's exact flags: clean on head and base
desktop-release.yml; live plant (broken expression) caught with the expected diagnostic. - shellcheck 0.11.0
-s bash: clean on all six extracted run blocks (sign block gets the step'senv: APPLE_SIGNING_IDENTITYas scaffold); live plant (unused variable, SC2034) caught. prettier --checkclean on the changed files; live plant (misformatting) caught.- eslint clean on the changed JS; live plant (unused variable) caught.
- yamllint: not installable (no pip in container) — see Not covered.
Findings (non-blocking, both carried from round 1)
F1 (nit, stands). Verify macOS signature uses codesign -d --entitlements - --xml "$app" while the repo's proven production pattern is codesign -d --entitlements :- (.github/workflows/live-host.yml:146). The --xml flag's existence still cannot be verified from this Linux container (no macOS toolchain; codesign is not in Apple's open-source releases). Bounded as before: every failure mode is fail-closed under the step's set -euo pipefail, so the worst case is a loud failure on the first signed release run, never a silent false green.
Suggested fix (unmeasured — macOS-only tool)
- codesign -d --entitlements - --xml "$app" > "$entitlements" 2>/dev/null
+ codesign -d --entitlements :- "$app" > "$entitlements" 2>/dev/nullF2 (coverage gap, stands). Re-measured at the new head via M5a/M5b above: deleting either new verification gate keeps test-release.js green, so a future workflow edit could drop them without a red test. The gates themselves pass this round's replay; this is completeness reporting, not a merge condition.
No new findings this round.
Not covered
- Real macOS behavior: TCC prompt, WKWebView mic capture, actual
codesign/spctl/PlistBuddysemantics, and the--xmlflag question (F1). The replay substitutes stubs for those tools (documented in Methodology), so it reproduces the gate logic, not the Apple-tool trigger path — the shape of the checks, not the cause side. Production-certificate signing was not exercised by the author either. yamllint(repo gate): container has nopip/pip3(pip3: Permission deniedfrom the installer;python3 -m pipabsent). YAML validity is established instead by actionlint's full-document parse and the js-yaml extraction.- Per-commit attribution: depth-2 checkout —
git rev-list HEAD^1..HEAD^2returns 1 commit while the snapshot lists 4 (e94c50c,4d44ed5,97e0e30,00eabce), so97e0e30and earlier are unreachable; verified the aggregateHEAD^1..HEADdiff only. - The stub replay remains uncalibrated: no real
desktop-releaserun artifact exists to byte-compare against (these steps run only on macOS release runners); calibration would be the log of the next real run. Round 1 said the same; nothing changed. - Rust
cargo testnot run (no Rust toolchain in container; the PR touches no Rust code).packages/desktop-shellhas no vitest suite — its release-helper suite istest-release.js, which is the A/B subject itself.
Methodology
Environment: CI verify container (node:22-bookworm; node v22.23.2, python 3.11.2, uid 1000), merge-ref checkout (HEAD = merge 025a613, HEAD^1 = base 3037744, HEAD^2 = head 00eabce), npm ci + npm run build pre-run at head. Scratch trees: git worktree add tmp/base-tree-pr8715 HEAD^1 and git worktree add tmp/head-scratch-pr8715 HEAD (both removed after the A/B cells were captured; recreate with those commands to re-run harnesses 01–03). Harnesses live in this artifact dir and drive real repo files: 01 runs the real test-release.js across head/base trees (4 A/B cells + 6 mutants + controls); its dependency closure imports only node: builtins (grep-verified) and the base tree has no node_modules, so no cross-tree resolution is possible (realpath checks in the harness output). 02 extracts the three changed run: blocks verbatim via js-yaml (round-trip-asserted substitutions: matrix.rust_target → aarch64-apple-darwin, /usr/libexec/PlistBuddy → PATH stub reimplementing Print :key semantics via python3 plistlib) and executes them under bash --noprofile --norc with recording stubs for codesign/spctl/npm against fixture bundles. 03 parses all three plists with plistlib and asserts exact key sets head vs base plus scope/closure facts. 04 runs the repo-pinned gates (actionlint 1.7.12 with scripts/lint.js flags, shellcheck 0.11.0, prettier, eslint), each with a planted-violation live control. Tauri wiring was re-confirmed from tauri-apps/tauri source at tag tauri-cli-v2.11.4. Raw per-cell logs in logs/; captures 01-04*.png are the harness runs as printed.
Evidence images
Harness scripts and raw logs are in the workflow run artifacts (7-day retention).
— Qwen Code · sandboxed verification
|
Resolved the two entitlement test gaps in
No screenshot: this is a packaging invariant with no meaningful UI state. 中文说明已在 |
|
The post-fix Ubuntu failure was outside this PR: four Per the CI refresh rule, I merged the latest |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Reviewed. Suggestions are inline. Not reviewed: reverse audit — stopped before round 3 by the review time budget. Not linted (tool limitation, not a blocker): the executable-script lint — .github/workflows/desktop-release.yml: actionlint embedded-shell source mapping is not yet supported — not linted.
中文说明
已审查。 建议见行内评论。 未审查:反向审计——评审时间预算不足,未能开始第 3 轮。 未检查(工具限制,非阻断):the executable-script lint — .github/workflows/desktop-release.yml: actionlint embedded-shell source mapping is not yet supported — not linted。
— qwen3.8-max via Qwen Code /review (v0.21.8)
Local verification on a real macOS stack — ✅ recommend mergeI built this PR's macOS app bundle locally and exercised the whole permission chain against the real OS, not just the plists. macOS 26.6 (25G72), arm64, Node 22.23.2, Tauri 2.11.5 / wry 0.55.1. Base for every A/B below: The result that mattersmacOS silently denies the microphone on the pre-PR bundle — no prompt is ever shown. With this PR, the native prompt appears carrying the exact usage string, and access is granted.
Method: a small Swift probe was made the bundle's The claim that could not be checked statically
Exactly one key added; The two new release gates, run verbatim
Both gates fail closed on the pre-PR artifact and pass on this one. I also confirmed The app still starts after hardened-runtime signing with the new entitlement set — Why the second commit (narrow helper entitlements) is load-bearingI pulled the shipped v0.1.0 arm64 DMG (sha256 matches the release
So without
That is expected: Are the new
|
| # | mutation | caught by |
|---|---|---|
| M1 | drop audio-input from Entitlements.plist |
the app bundle must keep microphone access for voice dictation |
| M2 | empty NSMicrophoneUsageDescription string |
must declare a non-empty microphone usage description |
| M3 | delete Info.plist (pre-PR state) |
hard ENOENT |
| M4 | give audio-input to bundled Node |
Node.js must not receive microphone access |
| M5 | strip allow-jit from NodeEntitlements.plist |
the bundled Node.js runtime must keep its JIT entitlement |
| M6 | re-attach app entitlements to ripgrep signing | ripgrep must not inherit the app entitlements |
| M7 | remove the audio-input check from the CI verify step |
the macOS signature check must keep verifying the audio-input entitlement |
| M8 | remove the NSMicrophoneUsageDescription check from the smoke step |
the packaged smoke must keep verifying the microphone usage description |
| M9 | point Node signing back at the app entitlements | Node.js must use its minimal helper entitlements |
Also clean: actionlint on desktop-release.yml, prettier --check on the workflow and test-release.js, and plutil -lint on all three plists.
Consistency check
packages/desktop/apps/electron/ already ships the byte-identical usage string and already carries com.apple.security.device.audio-input in build/entitlements.mac.plist. This brings the Tauri shell to parity rather than inventing new wording.
I also confirmed the chain has no other gap: wry 0.55.1 implements webView:requestMediaCapturePermissionForOrigin:… and unconditionally returns WKPermissionDecision::Grant (wry/src/wkwebview/class/wry_web_view_ui_delegate.rs:126), so the WebKit layer was never the blocker — TCC was, and TCC needs exactly the two things this PR adds. The shell loads the web-shell from http://127.0.0.1:<port>, a potentially-trustworthy origin, so getUserMedia in packages/web-shell/client/voice/useVoiceCapture.ts is in a secure context.
Minor, non-blocking
- M3 is an unhandled
ENOENT, not an assertion. If someone deletessrc-tauri/Info.plist,test:releasedies with a raw stack trace instead of the clear message every sibling check produces. Afs.existsSyncguard with a message would match the surrounding style. - The smoke gate accepts an empty string.
PlistBuddy -c 'Print :NSMicrophoneUsageDescription' … >/dev/nullsucceeds for<string></string>.test:releasecatches an empty value in the source plist, so the hole is only reachable if the bundler mangles it — worth a[ -n … ]if you want the bundle-level gate to be as strict as the source-level one. - The CI signature gate only checks the app. It asserts
audio-input = trueon the.appbut never asserts its absence on the signednode/rg. That invariant currently lives only intest:release's plist-text assertions, so a future edit to the signing step's--entitlementsargument would be caught, but a change to what those files contain at sign time would not.
Not covered
Production-certificate signing and notarization (ad-hoc signing only here, so spctl --assess was not meaningful), real sleep/wake, and the end-to-end voice-dictation UI — the voice button is gated behind a configured voice model and an authenticated session, so I verified the OS permission layer directly instead. Bundles were built with tauri build --debug; CI builds --release, but the Info.plist merge and entitlement signing are profile-independent.
中文版
在真实 macOS 环境本地验证 —— ✅ 建议合入
我在本地构建了这个 PR 的 macOS App bundle,并在真实系统上跑通了整条权限链路,而不只是检查 plist 文件。macOS 26.6 (25G72)、arm64、Node 22.23.2、Tauri 2.11.5 / wry 0.55.1。
下面所有 A/B 的基线都是 4a79517815(merge-base)。两个 bundle 来自同一棵源码树、同一次构建,只有 packages/desktop-shell/src-tauri/ 不同,并且都用 codesign --options runtime --entitlements <各自的集合> 做了 ad-hoc 签名。
最关键的结论
修复前的 bundle 会被 macOS 静默拒绝,系统弹窗根本不出现。加上这个 PR 之后,原生弹窗正常出现,并带着预期的用途说明文案,权限授予成功。
| 修复前 bundle | 本 PR 的 bundle | |
|---|---|---|
Contents/Info.plist 里的 NSMicrophoneUsageDescription |
缺失 | 存在 |
| 系统权限弹窗 | 从未出现 | 正常出现 |
AVCaptureDevice.requestAccess(for: .audio) |
granted=false |
granted=true |
最终 AVAuthorizationStatus |
denied |
authorized |
方法:把一个很小的 Swift 探针设为构建产物 .app 副本的 CFBundleExecutable,然后用 open 启动,让 LaunchServices 把请求归属到 App 自己。TCC 读取的所有东西——bundle id com.alibaba.qwen-code、Info.plist、entitlements、hardened runtime 标志——都完全是构建产物本身。如果直接从终端启动探针,TCC 会把请求归属到终端并使用终端自己的用途说明,那条路径无法验证本 PR,已弃用。
静态检查无法确认的那一条
tauri-codegen 的 Info.plist 合并逻辑(context.rs:303)被 dev && !running_tests 守住,不会在 tauri build 时执行。所以「把 src-tauri/Info.plist 放进 crate 目录就能进入打包产物」只可能由 bundler 完成。真实构建证明确实如此,且没有覆盖任何其他键:
$ diff <(plutil -p base.app/Contents/Info.plist) <(plutil -p head.app/Contents/Info.plist)
16a17
> "NSMicrophoneUsageDescription" => "Qwen Code uses the microphone for voice dictation in the prompt composer."
只多了一个键;CFBundleName、CFBundleIdentifier、CFBundleShortVersionString、LSMinimumSystemVersion 全部未变。
两个新增的发布门禁,原样执行
| 门禁 | 修复前 bundle | 本 PR 的 bundle |
|---|---|---|
PlistBuddy -c 'Print :NSMicrophoneUsageDescription'(smoke 步骤) |
exit 1 → 阻断发布 | exit 0 → 通过 |
codesign --verify --deep --strict --verbose=2 |
有效 | 有效 |
codesign -d --entitlements - --xml + PlistBuddy … audio-input = true |
键缺失 → 阻断发布 | true → 通过 |
两个门禁在修复前的产物上都 fail-closed,在本 PR 的产物上都通过。我还确认了 codesign -d --entitlements - --xml 和 test "$(…)" = true 的写法在 macOS 26.6 上行为符合预期(PlistBuddy 对 <true/> 输出裸 true)。
用新的 entitlement 集合做 hardened runtime 签名后 App 仍能正常启动:npm run smoke:packaged 输出 Packaged desktop runtime ready,真实窗口也能加载 web-shell。
第二个 commit(收窄 helper entitlements)为什么是必要的
我拉取了已发布的 v0.1.0 arm64 DMG(sha256 与 release 的 SHA256SUMS.txt 一致,签名是真实的 Developer ID Application: Alibaba Cloud (Singapore) Private Limited (NF4574S59H))。目前三个二进制携带的 entitlement 完全相同:
| 已发布 v0.1.0 二进制 | entitlements |
|---|---|
Qwen Code Desktop.app |
allow-jit、network.client、network.server |
内置 node |
allow-jit、network.client、network.server |
vendored rg |
allow-jit、network.client、network.server |
也就是说,如果没有 97e0e30,往 Entitlements.plist 里加 audio-input 会连带把麦克风 entitlement 发给 node 和 ripgrep。收窄是正确的,而且没有代价——在 hardened runtime 下 ad-hoc 签名后,两个 helper 前后行为完全一致:
node用旧集合 vs.NodeEntitlements.plist(仅 allow-jit):HTTP 服务端+客户端 OK、JIT/new FunctionOK、子进程 spawn OK——两者相同。rg用旧集合 vs. 完全不带 entitlements:ripgrep 15.0.0、搜索 OK——两者相同。
这符合预期:com.apple.security.network.* 属于 App Sandbox 的 entitlement,而这个 App 并未启用沙箱,所以移除它们在运行时是 no-op。
新增的 test:release 断言是真守卫吗?
我在沙箱副本里逐个回退被守护的值。9/9 变异全部被拦截,没有空断言:
| # | 变异 | 被谁拦下 |
|---|---|---|
| M1 | 从 Entitlements.plist 删掉 audio-input |
the app bundle must keep microphone access for voice dictation |
| M2 | 把 NSMicrophoneUsageDescription 置空 |
must declare a non-empty microphone usage description |
| M3 | 删除 Info.plist(回到修复前状态) |
直接 ENOENT |
| M4 | 给内置 Node 加上 audio-input |
Node.js must not receive microphone access |
| M5 | 从 NodeEntitlements.plist 去掉 allow-jit |
the bundled Node.js runtime must keep its JIT entitlement |
| M6 | 给 ripgrep 签名重新挂上 App entitlements | ripgrep must not inherit the app entitlements |
| M7 | 从 CI verify 步骤删掉 audio-input 校验 |
the macOS signature check must keep verifying the audio-input entitlement |
| M8 | 从 smoke 步骤删掉 NSMicrophoneUsageDescription 校验 |
the packaged smoke must keep verifying the microphone usage description |
| M9 | 把 Node 签名指回 App entitlements | Node.js must use its minimal helper entitlements |
另外全部通过:desktop-release.yml 的 actionlint、工作流与 test-release.js 的 prettier --check、三个 plist 的 plutil -lint。
一致性
packages/desktop/apps/electron/ 已经在用逐字相同的用途说明文案,并且 build/entitlements.mac.plist 里已经有 com.apple.security.device.audio-input。本 PR 是让 Tauri shell 与之对齐,而不是新造措辞。
我也确认链路上没有其他缺口:wry 0.55.1 实现了 webView:requestMediaCapturePermissionForOrigin:… 并无条件返回 WKPermissionDecision::Grant(wry/src/wkwebview/class/wry_web_view_ui_delegate.rs:126),所以 WebKit 这一层从来不是阻塞点,卡住的是 TCC,而 TCC 需要的恰好就是本 PR 补上的这两样。Shell 从 http://127.0.0.1:<port> 加载 web-shell,属于 potentially-trustworthy origin,因此 packages/web-shell/client/voice/useVoiceCapture.ts 里的 getUserMedia 处于安全上下文。
次要问题,不阻塞合入
- M3 抛的是未处理的
ENOENT,不是断言。 如果有人删掉src-tauri/Info.plist,test:release会直接抛裸栈,而不是像相邻检查那样给出清晰信息。加一个带消息的fs.existsSync守卫会更贴合周围风格。 - smoke 门禁接受空字符串。 对
<string></string>,PlistBuddy -c 'Print :NSMicrophoneUsageDescription' … >/dev/null仍会成功。test:release会在源 plist 上拦下空值,所以这个洞只有在 bundler 出问题时才可达——如果希望 bundle 级门禁和源码级一样严格,可以补一个[ -n … ]。 - CI 签名门禁只检查了 App。 它断言
.app上audio-input = true,但从未断言签名后的node/rg上没有这个 entitlement。该不变量目前只存在于test:release的 plist 文本断言里;也就是说改动签名步骤的--entitlements参数会被抓到,但改动这些文件在签名时的内容不会。
未覆盖
生产证书签名与公证(这里只做了 ad-hoc 签名,所以 spctl --assess 没有参考意义)、真实休眠/唤醒,以及端到端的语音听写 UI——语音按钮需要已配置的语音模型和已认证会话才会出现,因此我改为直接验证操作系统权限层。产物用 tauri build --debug 构建;CI 走 --release,但 Info.plist 合并与 entitlement 签名与 profile 无关。
doudouOUC
left a comment
There was a problem hiding this comment.
Approving — re-reviewed at 18a812f88573733b62576ddd66bc4d547600597e. My earlier approval was dismissed by the push, so this is a fresh look at the delta since 00eabce419, plus a re-check of what I raised last round. Both of my Suggestions are addressed, and the coverage complaint is resolved. One residual guard gap remains, non-blocking, with a reproduction.
R1-2 is genuinely fixed — I re-ran my own evasion
Last round I showed that assert.doesNotMatch(workflow, /--entitlements src-tauri\/Entitlements\.plist \{\} \+/) pinned a text shape, not a property: re-adding --entitlements on its own continuation line left all five assertions green while ripgrep went back to being signed with the app's full entitlement set, microphone included.
The block-slice replacement closes that. I applied the same evasion to this head and it now fails, along with every other mutation I could think of — 8/8 caught:
baseline (unmutated): passes ✅
caught M1 re-add --entitlements to ripgrep on its own continuation line (last round's evasion)
caught M2 same evasion + rename the "# ripgrep vendor binaries" slice marker
caught M3 drop audio-input from the app Entitlements.plist
caught M4 empty the NSMicrophoneUsageDescription string
caught M5 drop allow-jit from NodeEntitlements.plist
caught M6 grant audio-input to the bundled Node runtime
caught M7 delete the release-time audio-input entitlement assertion
caught M8 delete the release-time NSMicrophoneUsageDescription assertion
restored; baseline again: passes ✅
M3–M8 are the R1-1 gap — the four positive invariants plus both release-time assertion lines that were unguarded last round. They are all pinned now. The rewrite of the old assert.ok(includes('--entitlements src-tauri/Entitlements.plist {} +')) into assert.match(/--options runtime --timestamp \\\n\s+\{\} \+/) also preserves the original intent of that assertion (the -exec … + form, so a codesign failure fails the step) without dragging the entitlements flag back in. Good adaptation.
Coverage: my previous blocker is gone
Last round there were zero workflow runs on the reviewed head. This head has four, and both Desktop Shell (ubuntu-22.04) and Desktop Shell (windows-2022) are green — that is the job which runs node scripts/test-release.js (and cargo test), i.e. the guard this PR extends actually executed against this change. Test (ubuntu-latest, Node 22.x) and the rest are green too. Nothing further owed here.
Residual, non-blocking: the slice anchor can make the guard vacuous
ripgrepSigningBlock is workflow.slice(indexOf('# ripgrep vendor binaries'), indexOf('# Node.js runtime binary')). If the opening marker is ever renamed, indexOf returns -1, slice(-1, end) yields the empty string, and assert.doesNotMatch('', /--entitlements/) passes for free — the guard stops guarding without saying so.
That is not reachable by the marker rename alone (M2 above is caught) but only because a different assertion — the --options runtime --timestamp \ + {} + continuation form — happens to break when you insert the flag there. Put the flag one line earlier instead, where that form stays intact, and both fire blank:
caught M9 --entitlements before --options runtime, marker intact
NOT CAUGHT M10 same, but the ripgrep marker is renamed
NOT CAUGHT M11 marker renamed only (no entitlements added)
caught M12 closing marker "# Node.js runtime binary" renamed only
M10 is a two-part edit — reorder codesign args, reword a comment — each half of which looks innocent in review, and it puts the app's full entitlements (microphone included) back on the vendored rg with the whole suite green. Nothing downstream catches it either: the release-time entitlement dump inspects the .app bundle, never ripgrep.
Note the asymmetry, which is worth keeping: renaming the closing marker fails closed (M12 — the block then runs to EOF and swallows Node's --entitlements). Only the opening marker fails open. One line fixes it:
const rgStart = workflow.indexOf('# ripgrep vendor binaries');
const rgEnd = workflow.indexOf('# Node.js runtime binary');
assert.ok(rgStart !== -1 && rgEnd > rgStart, 'the ripgrep signing block must be locatable');Suggestion severity, same as last round's: the workflow as written today signs ripgrep correctly, so this is guard robustness, not a live defect. But the entire point of the entitlement split is that the next edit cannot quietly undo it, and this is now the only thing standing between that and a green suite.
Unchanged from last round
Still true, still unverifiable at PR time, still correctly handled by the release-time assertions: whether Tauri actually merges src-tauri/Info.plist into the built bundle (its only wiring is filename auto-discovery — nothing in the repo references it), and whether the entitlement lands on the signed binary. Those need a real signed macOS build; the workflow_dispatch assertions are the right place for them and they are both now pinned by tests.
中文说明
批准 —— 在 18a812f88573733b62576ddd66bc4d547600597e 上重新审查。我之前的批准已被推送 dismiss,因此这次是对 00eabce419 之后增量的新一轮审查,并复核我上轮提出的问题。两条 Suggestion 都已处理,覆盖率方面的阻塞项也已解决。仍有一处残留的守卫缺口,不阻塞,附复现。
R1-2 确实修好了——我重跑了自己的绕过手法
上轮我证明 assert.doesNotMatch(workflow, /--entitlements src-tauri\/Entitlements\.plist \{\} \+/) 钉的是文本形状而非属性:把 --entitlements 加在独立续行上,五条断言全绿,而 ripgrep 又会以应用完整权限集(含麦克风)签名。
改用"切出 ripgrep 签名块"后这条路被堵住了。我把同样的绕过手法应用到本 head,现在会失败;我能想到的其他变异也都被捕获——8/8:
基线(未变异):通过 ✅
捕获 M1 把 --entitlements 以独立续行重新加回 ripgrep(上轮的绕过手法)
捕获 M2 同样的绕过 + 重命名 "# ripgrep vendor binaries" 切片标记
捕获 M3 从应用 Entitlements.plist 删除 audio-input
捕获 M4 把 NSMicrophoneUsageDescription 文案清空
捕获 M5 从 NodeEntitlements.plist 删除 allow-jit
捕获 M6 给随附 Node 运行时授予 audio-input
捕获 M7 删除发布期 audio-input 权限断言
捕获 M8 删除发布期 NSMicrophoneUsageDescription 断言
恢复后基线:通过 ✅
M3–M8 正是 R1-1 的缺口——上轮无守卫的四条正向不变式加两条发布期断言行,现已全部钉住。把旧的 assert.ok(includes('--entitlements src-tauri/Entitlements.plist {} +')) 改写为 assert.match(/--options runtime --timestamp \\\n\s+\{\} \+/),同时保留了该断言原本的意图(-exec … + 形态,使 codesign 失败能让步骤失败),且没有把 entitlements 标志重新拖回来。改写得当。
覆盖率:我上轮的阻塞项已消除
上轮被审 head 上的 workflow 运行数为零。本 head 有四次运行,且 Desktop Shell (ubuntu-22.04) 与 (windows-2022) 均为绿色——正是运行 node scripts/test-release.js(以及 cargo test)的那个 job,也就是本 PR 所扩展的守卫,这次真的对本改动执行了。Test (ubuntu-latest, Node 22.x) 等也为绿。此项无遗留。
残留(不阻塞):切片锚点可能让守卫变成空断言
ripgrepSigningBlock 是 workflow.slice(indexOf('# ripgrep vendor binaries'), indexOf('# Node.js runtime binary'))。一旦起始标记被改名,indexOf 返回 -1,slice(-1, end) 得到空字符串,于是 assert.doesNotMatch('', /--entitlements/) 白白通过——守卫停止守卫而不作声。
仅改名标记还触发不了它(上面 M2 被捕获),但那只是因为另一条断言——--options runtime --timestamp \ + {} + 的续行形态——恰好会因为在那里插入标志而失效。把标志往前挪一行、让该形态保持完整,两条断言就都会落空:
捕获 M9 --entitlements 放在 --options runtime 之前,标记不变
未捕获 M10 同上,但 ripgrep 标记被改名
未捕获 M11 仅改名标记(未加 entitlements)
捕获 M12 仅改名收尾标记 "# Node.js runtime binary"
M10 是一次两部分的编辑——调整 codesign 参数顺序 + 改一句注释——两半在评审里各自看起来都很无辜,合起来却让随附的 rg 重新带上应用完整权限(含麦克风),而整套断言全绿。下游也拦不住:发布期的权限导出只检查 .app bundle,从不检查 ripgrep。
注意这处不对称,值得保留:改名收尾标记是 fail-closed(M12——此时块一直延伸到文件尾,把 Node 的 --entitlements 一并吞进来)。只有起始标记是 fail-open。一行即可修复:
const rgStart = workflow.indexOf('# ripgrep vendor binaries');
const rgEnd = workflow.indexOf('# Node.js runtime binary');
assert.ok(rgStart !== -1 && rgEnd > rgStart, 'the ripgrep signing block must be locatable');定级与上轮相同,为 Suggestion:今天的 workflow 对 ripgrep 的签名是正确的,所以这是守卫健壮性问题而非现行缺陷。但权限拆分的全部意义就在于下一次改动不能悄悄把它撤销,而现在这已是横在"撤销"与"全绿套件"之间的唯一屏障。
与上轮相同、未变的部分
依旧成立、依旧无法在 PR 期验证、也依旧由发布期断言正确兜底:Tauri 是否真的把 src-tauri/Info.plist 合并进构建产物(它唯一的接入方式是按文件名自动发现——仓库里没有任何地方引用它),以及权限是否真的落在已签名二进制上。这两者需要真实的 macOS 签名构建;workflow_dispatch 的断言正是它们该待的地方,且现在都已被测试钉住。
— reviewed by doudouOUC
|
Closeout for
中文摘要发布校验现在会在签名段 marker 缺失时失败,并确认 macOS 麦克风权限与 Node JIT 权限值均为 |
doudouOUC
left a comment
There was a problem hiding this comment.
Approving again — reviewed at f294c8d80c876aee451f2e567393e67dab09c4c2. My approval from twenty minutes ago was dismissed by this push, so here is the delta review: the one commit since 18a812f885 is test(desktop): strengthen entitlement release guards, and it closes exactly the residual gap I reported — plus two hardenings I had not asked for.
The fail-open is gone
The guard now validates both markers before slicing:
const ripgrepStart = workflow.indexOf('# ripgrep vendor binaries');
const ripgrepEnd = workflow.indexOf('# Node.js runtime binary');
assert.ok(ripgrepStart !== -1 && ripgrepEnd > ripgrepStart, '…must keep its ripgrep/Node section markers');I re-ran both of my probes against this head. The two cases that slipped through last round are now caught, and the whole set holds:
caught M9 --entitlements before --options runtime, marker intact
caught M10 same, but the ripgrep marker is renamed ← was NOT CAUGHT
caught M11 marker renamed only (no entitlements added) ← was NOT CAUGHT
caught M12 closing marker renamed only
caught M1–M8 (last round's full set, re-run)
baseline: passes ✅
M11 being caught is the better outcome than the one I proposed: renaming the marker now fails immediately and on its own, rather than only when combined with a bad codesign edit. The guard says "I can no longer find what I am supposed to check" instead of silently checking nothing.
Two hardenings beyond the ask
The positive assertions moved from bare substring matches to key/value pairs:
/com\.apple\.security\.device\.audio-input/ → /<key>com\.apple\.security\.device\.audio-input<\/key>\s*<true\/>/
/com\.apple\.security\.cs\.allow-jit/ → /<key>com\.apple\.security\.cs\.allow-jit<\/key>\s*<true\/>/
That closes a hole neither of us had named: a key present but set to <false/> satisfied the old regex, so a disabled entitlement read as a healthy one. I probed both:
caught N1 app audio-input set to <false/> (key still present)
caught N2 Node allow-jit set to <false/> (key still present)
Both would have passed before this commit. Note the negative assertion (nodeEntitlements must not carry audio-input) correctly stays a bare match — there, mentioning the key at all is what matters, and requiring <true/> would have introduced the same class of hole in reverse.
Nothing else in the diff. Desktop Shell is green on ubuntu-22.04 and windows-2022 at this head; the Node lane is still running. Everything from my previous review stands, minus the residual finding, which is now resolved.
中文说明
再次批准 —— 审查提交 f294c8d80c876aee451f2e567393e67dab09c4c2。我二十分钟前的批准被这次推送 dismiss,因此这是增量审查:18a812f885 之后的唯一提交是 test(desktop): strengthen entitlement release guards,它正好补上了我报告的残留缺口,另外还多做了两处我并未要求的加固。
fail-open 已消除
守卫现在会在切片之前校验两个标记:
const ripgrepStart = workflow.indexOf('# ripgrep vendor binaries');
const ripgrepEnd = workflow.indexOf('# Node.js runtime binary');
assert.ok(ripgrepStart !== -1 && ripgrepEnd > ripgrepStart, '…must keep its ripgrep/Node section markers');我把两个探针都在本 head 上重跑了一遍。上一轮漏掉的两个用例现已被捕获,整套结果保持成立:
捕获 M9 --entitlements 放在 --options runtime 之前,标记不变
捕获 M10 同上,但 ripgrep 标记被改名 ← 上轮未捕获
捕获 M11 仅改名标记(未加 entitlements) ← 上轮未捕获
捕获 M12 仅改名收尾标记
捕获 M1–M8(上轮全套,已重跑)
基线:通过 ✅
M11 被捕获,比我提议的方案更好:改名标记现在会立即、独立地失败,而不是只有与错误的 codesign 编辑组合时才失败。守卫会说"我已经找不到该检查的东西了",而不是默默地什么都不检查。
超出要求的两处加固
两条正向断言从裸子串匹配改成了键值对匹配:
/com\.apple\.security\.device\.audio-input/ → /<key>com\.apple\.security\.device\.audio-input<\/key>\s*<true\/>/
/com\.apple\.security\.cs\.allow-jit/ → /<key>com\.apple\.security\.cs\.allow-jit<\/key>\s*<true\/>/
这补上了我们双方都没点出的一个漏洞:键存在但被设为 <false/> 也能满足旧正则,于是被禁用的权限会被读成健康状态。两种情形我都探测了:
捕获 N1 应用 audio-input 被设为 <false/>(键仍存在)
捕获 N2 Node allow-jit 被设为 <false/>(键仍存在)
在本提交之前,这两种情形都会通过。另外注意:否定断言(nodeEntitlements 不得含 audio-input)正确地保持了裸匹配——在那里,只要提到该键就要报警,若也要求 <true/> 反而会引入同一类漏洞的反向版本。
diff 中没有其他内容。本 head 上 Desktop Shell 在 ubuntu-22.04 与 windows-2022 均为绿色,Node 通道仍在运行。我上一轮审查的其余结论全部维持,唯一的残留发现现已解决。
— reviewed by doudouOUC
wenshao
left a comment
There was a problem hiding this comment.
Reviewed. Suggestions are inline. Not linted (tool limitation, not a blocker): the executable-script lint — .github/workflows/desktop-release.yml: actionlint embedded-shell source mapping is not yet supported — not linted.
中文说明
已审查。 建议见行内评论。 未检查(工具限制,非阻断):the executable-script lint — .github/workflows/desktop-release.yml: actionlint embedded-shell source mapping is not yet supported — not linted。
— DeepSeek/deepseek-v4-flash via Qwen Code /review (v0.21.8)
| codesign -d --entitlements - --xml "$app" > "$entitlements" 2>/dev/null | ||
| test "$(/usr/libexec/PlistBuddy -c 'Print :com.apple.security.device.audio-input' "$entitlements")" = true |
There was a problem hiding this comment.
[Suggestion] Release-time verification is asymmetric: the new artifact-level assertions check only the app's main executable (audio-input entitlement here, usage description in Contents/Info.plist). The PR's actual behavioral change — Node signed with NodeEntitlements.plist (allow-jit only), ripgrep signed with no entitlements — has no release-time artifact check; only the workflow-text/source-file unit assertions cover it. — Failure scenario: a future divergence between the source plists and the final bundle — a build step re-signing or replacing the embedded node/rg binaries, or the signing step dropping the NodeEntitlements.plist flag while the workflow-text test is updated in the same commit — ships Node carrying com.apple.security.device.audio-input (or ripgrep the full app entitlements), exactly the exposure this PR exists to prevent. codesign --verify --deep --strict checks validity, not entitlement content; every release gate stays green.
| codesign -d --entitlements - --xml "$app" > "$entitlements" 2>/dev/null | |
| test "$(/usr/libexec/PlistBuddy -c 'Print :com.apple.security.device.audio-input' "$entitlements")" = true | |
| node_bin="$app/Contents/Resources/runtime/qwen-code/node/bin/node" | |
| if [ -f "$node_bin" ]; then | |
| node_entitlements="$(mktemp)" | |
| codesign -d --entitlements - --xml "$node_bin" > "$node_entitlements" 2>/dev/null | |
| test "$(/usr/libexec/PlistBuddy -c 'Print :com.apple.security.device.audio-input' "$node_entitlements")" != true | |
| test "$(/usr/libexec/PlistBuddy -c 'Print :com.apple.security.cs.allow-jit' "$node_entitlements")" = true | |
| rm -f "$node_entitlements" | |
| fi |
中文说明
发布期验证不对称:新增的产物级断言只检查 App 主可执行文件(此处的 audio-input 权限,以及 Contents/Info.plist 里的用途说明)。本 PR 真正的行为变更——Node 改用仅含 allow-jit 的 NodeEntitlements.plist 签名、ripgrep 不带任何权限签名——没有任何发布期产物检查,只有 workflow 文本/源文件级的单元断言在覆盖。— 失败场景:未来源 plist 与最终 bundle 出现偏差——某步构建重新签名或替换内嵌的 node/rg 二进制,或签名步骤丢掉 NodeEntitlements.plist 参数且同一提交里改了 workflow 文本测试——Node 带着 com.apple.security.device.audio-input(或 ripgrep 带着完整应用权限)发布,这正是本 PR 要防止的暴露。codesign --verify --deep --strict 只检查签名有效性,不检查权限内容;所有发布关卡仍然全绿。
— DeepSeek/deepseek-v4-flash via Qwen Code /review (v0.21.8)
| assert.match( | ||
| workflow, | ||
| /--options runtime --timestamp \\\n\s+\{\} \+/, | ||
| 'ripgrep codesign failures must fail the signing step', | ||
| ); |
There was a problem hiding this comment.
[Suggestion] The assertion message 'ripgrep codesign failures must fail the signing step' (carried over from the pre-diff assertion) does not match what the regex tests: /--options runtime --timestamp \\n\s+\{\} \+/ only pins the codesign command's syntactic continuation shape. The property the message names actually lives in set -euo pipefail at the top of the 'Sign bundled vendor binaries (macOS)' step — and no assertion in test-release.js checks it. — Failure scenario: set -e (or set -euo pipefail) is removed from that step, or the codesign command is detached from that shell context → a failing codesign becomes a non-fatal warning → unsigned/partially-signed helper binaries ship in the release, while this test, whose stated purpose is exactly to prevent that, still passes.
| assert.match( | |
| workflow, | |
| /--options runtime --timestamp \\\n\s+\{\} \+/, | |
| 'ripgrep codesign failures must fail the signing step', | |
| ); | |
| const signingStep = workflow.slice( | |
| workflow.indexOf("name: 'Sign bundled vendor binaries (macOS)'"), | |
| workflow.indexOf("name: 'Build desktop installers'"), | |
| ); | |
| assert.match( | |
| signingStep, | |
| /set -euo pipefail/, | |
| 'the signing step must keep failing closed on codesign errors', | |
| ); | |
| assert.match( | |
| workflow, | |
| /--options runtime --timestamp \\n\s+\{\} \+/, | |
| 'the ripgrep codesign command must keep its hardened-runtime flags', | |
| ); |
中文说明
断言消息 "ripgrep codesign failures must fail the signing step"(沿用自改动前的断言)与正则实际测试的内容不符:/--options runtime --timestamp \\n\s+\{\} \+/ 只固定了 codesign 命令的语法续行形态。消息所指的属性实际存在于 'Sign bundled vendor binaries (macOS)' 步骤顶部的 set -euo pipefail——而 test-release.js 中没有任何断言检查它。— 失败场景:该步骤删掉 set -e(或 set -euo pipefail),或 codesign 命令脱离该 shell 上下文 → 失败的 codesign 变成非致命警告 → 未签名/部分签名的辅助二进制随发布流出,而这个声称正是要阻止该情况的测试仍然通过。
— DeepSeek/deepseek-v4-flash via Qwen Code /review (v0.21.8)
| entitlements="$(mktemp)" | ||
| trap 'rm -f "$entitlements"' EXIT | ||
| codesign -d --entitlements - --xml "$app" > "$entitlements" 2>/dev/null | ||
| test "$(/usr/libexec/PlistBuddy -c 'Print :com.apple.security.device.audio-input' "$entitlements")" = true |
There was a problem hiding this comment.
[Suggestion] Dry-run rehearsal silently skips the app-side mic-entitlement gate: the audio-input check landed in 'Verify macOS signature' (if: runner.os == 'macOS' && inputs.dry_run == false), while the sibling NSMicrophoneUsageDescription check went into 'Smoke packaged application' (ungated). Since dry_run defaults to true and this workflow is workflow_dispatch-only, the codesign-dump path executes nowhere — not in CI, not in a dry-run rehearsal — until the first real signed release. Separately, tauri.conf.json's bundle.macOS.entitlements: "Entitlements.plist" is asserted by no test. — Failure scenario: an operator triggers the default dry-run to rehearse a release; the smoke step's Info.plist check passes green but the audio-input assertion is skipped, so a regression such as bundle.macOS.entitlements being repointed at NodeEntitlements.plist sails through the dry-run and is first caught on the real release run — whose codesign-dump assertion has never executed anywhere before.
中文说明
干跑排练会静默跳过应用侧麦克风权限关卡:audio-input 检查放在了 'Verify macOS signature'(if: runner.os == 'macOS' && inputs.dry_run == false),而同族的 NSMicrophoneUsageDescription 检查放在了 'Smoke packaged application'(不设门控)。由于 dry_run 默认是 true,且该 workflow 仅由 workflow_dispatch 触发,codesign 导出路径在任何地方都不会执行——CI 不执行、干跑也不执行——直到第一次真实签名发布。另外,tauri.conf.json 中的 bundle.macOS.entitlements: "Entitlements.plist" 没有任何测试断言。— 失败场景:操作员触发默认干跑来排练发布;smoke 步骤的 Info.plist 检查通过,但 audio-input 断言被跳过,于是诸如把 bundle.macOS.entitlements 改指向 NodeEntitlements.plist 之类的回归可以安然通过干跑,直到真实发布时才被发现——而那条 codesign 导出断言此前从未在任何地方执行过。
— DeepSeek/deepseek-v4-flash via Qwen Code /review (v0.21.8)
| /--options runtime --timestamp \\\n\s+\{\} \+/, | ||
| 'ripgrep codesign failures must fail the signing step', |
There was a problem hiding this comment.
[Suggestion] The regex /--options runtime --timestamp \\n\s+\{\} \+/ pins the rg codesign command's exact flag sequence AND its multi-line continuation shape; any semantically-correct future edit — a flag reorder or a new flag inserted before {} — makes the assertion fail, breaking CI on correct changes rather than on regressions (probe-verified: inserting --verbose=4 between --timestamp and {} fails the suite; revert restores green). — Failure scenario: a future maintainer adds a flag to the ripgrep codesign (e.g. --verbose=4) or reorders flags while keeping the command fully valid → assert.match fails → CI goes red on a correct change, and the author must discover that this whitespace-sensitive regex — not the signing — is what broke.
中文说明
正则 /--options runtime --timestamp \\n\s+\{\} \+/ 同时固定了 rg codesign 命令的精确参数顺序和多行续行形态;任何语义正确的未来改动——调整参数顺序或在 {} 前插入新参数——都会让断言失败,CI 因正确改动而非回归变红(探针实测:在 --timestamp 与 {} 之间插入 --verbose=4 会让套件失败,还原后恢复绿色)。— 失败场景:未来维护者给 ripgrep codesign 加一个参数(如 --verbose=4)或调整参数顺序但命令完全合法 → assert.match 失败 → CI 因正确改动变红,作者还得查明是这个对空白敏感的 regex——而不是签名本身——坏了事。
— DeepSeek/deepseek-v4-flash via Qwen Code /review (v0.21.8)
| assert.ok( | ||
| workflow.includes( | ||
| '--entitlements src-tauri/NodeEntitlements.plist "$node_bin"', | ||
| ), |
There was a problem hiding this comment.
[Suggestion] The test pins the ripgrep sibling's full invocation (--options runtime --timestamp \ + {} +) but pins only the entitlements argument for the Node sibling — the hardened-runtime flags on the Node codesign (--options runtime --timestamp) are asserted nowhere. Asymmetric guard (probe-verified: deleting those flags from the Node line leaves the suite green). — Failure scenario: a future edit drops --options runtime --timestamp from the Node codesign line (leaving --entitlements src-tauri/NodeEntitlements.plist "$node_bin" intact) → the assertion still passes on the substring → the suite greenlights a Node binary signed without hardened runtime, and the regression surfaces only at release (child-process launch failure under the hardened parent, or notarization rejection).
| assert.ok( | |
| workflow.includes( | |
| '--entitlements src-tauri/NodeEntitlements.plist "$node_bin"', | |
| ), | |
| assert.match( | |
| workflow, | |
| /codesign --force --sign "\$APPLE_SIGNING_IDENTITY" \\n\s+--options runtime --timestamp \\n\s+--entitlements src-tauri\/NodeEntitlements\.plist "\$node_bin"/, | |
| 'Node.js must keep its hardened-runtime flags and minimal entitlements', | |
| ); |
中文说明
测试固定了 ripgrep 兄弟命令的完整调用形态(--options runtime --timestamp \ + {} +),但对 Node 兄弟只固定了 entitlements 参数——Node codesign 行上的 hardened-runtime 标志(--options runtime --timestamp)没有任何断言。守卫不对称(探针实测:删掉 Node 行的这些标志后套件仍然全绿)。— 失败场景:未来某次编辑从 Node codesign 行删掉 --options runtime --timestamp(保留 --entitlements src-tauri/NodeEntitlements.plist "$node_bin")→ 断言仍能匹配子串 → 套件给一个没有 hardened runtime 签名的 Node 二进制放行,回归要到发布时才暴露(在 hardened 父进程下子进程启动失败,或公证被拒)。
— DeepSeek/deepseek-v4-flash via Qwen Code /review (v0.21.8)
| assert.match( | ||
| workflow, | ||
| /Print :com\.apple\.security\.device\.audio-input/, | ||
| 'the macOS signature check must keep verifying the audio-input entitlement', | ||
| ); |
There was a problem hiding this comment.
[Suggestion] The regex pins only the PlistBuddy key name, not the load-bearing = true comparison on the same workflow line. Mutation-verified: deleting = true from that workflow line leaves the unit suite fully green. — Failure scenario: a packaged app whose com.apple.security.device.audio-input entitlement is <false/> (or a non-boolean value) then passes the release gate that this test exists to guard. (The missing-key case is still caught via the empty output; the false-value case is not.)
| assert.match( | |
| workflow, | |
| /Print :com\.apple\.security\.device\.audio-input/, | |
| 'the macOS signature check must keep verifying the audio-input entitlement', | |
| ); | |
| assert.match( | |
| workflow, | |
| /Print :com\.apple\.security\.device\.audio-input' "\$entitlements"\)" = true/, | |
| 'the macOS signature check must keep verifying the audio-input entitlement', | |
| ); |
中文说明
正则只固定了 PlistBuddy 的键名,没有固定同一行上承载核心语义的 = true 比较。变异实测:从 workflow 那行删掉 = true 后,单元套件仍然全绿。— 失败场景:打包应用里 com.apple.security.device.audio-input 权限被设为 <false/>(或非布尔值)时,本测试要守护的发布关卡仍然放行。(键缺失的情况仍会因空输出被抓到;false 值的情况抓不到。)
— DeepSeek/deepseek-v4-flash via Qwen Code /review (v0.21.8)
|
Released in v0.21.9. |















What this PR does
This completes the macOS permission chain for Desktop voice dictation. Packaged apps now include the user-facing microphone usage description and the Hardened Runtime audio-input entitlement, and release CI verifies both values on the final app bundle.
Why it's needed
Desktop v0.1.0 shipped without a microphone usage description. The signed app also lacked the audio-input entitlement required by the macOS Hardened Runtime, so WKWebView microphone capture could not complete the native permission flow reliably.
Reviewer Test Plan
How to verify
Build the macOS app, sign it with the configured entitlement set, and inspect the final bundle. The app metadata should contain a microphone usage description, the signature should contain
com.apple.security.device.audio-input = true, and the existing packaged-app smoke should still start the bundled runtime successfully.Evidence (Before & After)
Before: the official Desktop v0.1.0 arm64 bundle has no
NSMicrophoneUsageDescription, and its signed executable has no audio-input entitlement.After: a locally rebuilt bundle contains the expected usage description; after local Hardened Runtime signing, strict signature verification passes and the extracted audio-input entitlement is
true.Tested on
Environment (optional)
macOS arm64. Verified the official v0.1.0 DMG, Desktop Rust tests (23 passed), release helper tests, a debug app bundle, local ad-hoc Hardened Runtime signing, plist validation, Prettier, actionlint, and the repository build/typecheck plus focused SSE, WebSocket, and voice tests.
Risk & Scope
mainby fix(web-shell): allow session refresh with daemon auth #8445.Linked Issues
Part of #8092.
中文说明
这个 PR 做了什么
这个 PR 补全 Desktop 语音听写在 macOS 上的权限链路。打包后的应用现在会包含面向用户的麦克风用途说明和 Hardened Runtime 音频输入 entitlement,发布 CI 也会在最终 App 产物上验证这两个值。
为什么需要它
Desktop v0.1.0 发布包缺少麦克风用途说明,已签名应用也缺少 macOS Hardened Runtime 所要求的音频输入 entitlement,因此 WKWebView 的麦克风采集无法可靠完成系统权限流程。
Reviewer 测试计划
如何验证
构建 macOS App,使用配置的 entitlement 完成签名,然后检查最终 bundle。应用元数据应包含麦克风用途说明,签名应包含
com.apple.security.device.audio-input = true,现有打包 App smoke 仍应能正常启动内置 runtime。证据(修复前 / 修复后)
修复前:官方 Desktop v0.1.0 arm64 bundle 没有
NSMicrophoneUsageDescription,已签名可执行文件也没有音频输入 entitlement。修复后:本地重新构建的 bundle 包含预期用途说明;完成本地 Hardened Runtime 签名后,严格签名校验通过,提取出的音频输入 entitlement 为
true。已测试平台
环境(可选)
macOS arm64。已验证官方 v0.1.0 DMG、Desktop Rust 测试(23 项通过)、发布辅助测试、debug App bundle、本地 ad-hoc Hardened Runtime 签名、plist 校验、Prettier、actionlint,以及仓库 build/typecheck 和 SSE、WebSocket、语音相关定向测试。
风险与范围
main上的 fix(web-shell): allow session refresh with daemon auth #8445 修复。关联 Issue
#8092 的一部分。