Add Corveil CLI path picker + auto-install /query-corveil on launch#483
Conversation
dhilgaertner
left a comment
There was a problem hiding this comment.
Code & Security Review
Clean, well-documented feature. The generic defaults.binaries map with decodeIfPresent backward-compat, the off-main Task.detached Verify path, and the non-fatal ScaffoldResult.warning surfaced via the established <X>Warning banner pattern are all done right. Two should-fix items below.
Critical Issues
None.
Security Review
Strengths:
- No shell involved —
Processis invoked withexecutableURL+ a fixedargumentsarray (["skill","install","--path",target]/["--version"]), so a path or target with spaces/metacharacters cannot inject. Arg-injection safe. isExecutableFile(atPath:)gate before exec; non-executable path is a no-op warning, never a crash.- The binary is the user's own locally-configured CLI — no trust-boundary crossing.
Concerns:
- None.
Code Quality
🟡 Launch-path subprocess blocks the main thread with no timeout — Sources/Crow/App/Scaffolder.swift:180. installCorveilSkill calls proc.waitUntilExit() synchronously, and scaffold(...) runs on the main thread during applicationDidFinishLaunching → launchMainApp (AppDelegate.swift:85,316,403). A corveil binary that hangs (blocks on stdin, opens a prompt, wrong executable) freezes app startup indefinitely — with no window yet drawn and the single-instance lock already held, the user has no escape. Note the inconsistency: the Verify button correctly uses Task.detached to avoid blocking the UI (SettingsView.swift), yet the riskier launch path runs unguarded. The isExecutableFile gate prevents a missing/non-exec binary but does not bound runtime. Suggest either running the install off the main actor (and assigning corveilSkillInstallWarning via MainActor.run) or adding a wall-clock timeout that terminates the process and returns a warning.
🟡 No test coverage for the new binaries field or the corveil helpers — the field added immediately before this one, ignoreReviewLabels, has both ignoreReviewLabelsRoundTrip() and ignoreReviewLabelsDefaultsEmptyWhenKeyMissing() (Packages/CrowCore/Tests/CrowCoreTests/AppConfigTests.swift:441,450). binaries has neither, so the round-trip and the decodeIfPresent ?? [:] backward-compat path are unverified. runCorveilVersion(at:) is nonisolated static and described in its own doc comment as "trivially testable," yet has no test. Adding a round-trip + missing-key test (matching the established convention) and a couple of runCorveilVersion cases (non-executable path, exit-code formatting) would close the gap.
🟢 corveil install runs twice on first-time setup — completeSetup runs scaffold(...) (AppDelegate.swift:303) and then calls launchMainApp() which runs scaffold(...) again (AppDelegate.swift:403), so the subprocess fires twice on the initial-setup launch. Harmless given idempotency, but the line-308 assignment is immediately superseded by line 408.
🟢 The corveilBinding empty-string-removes-key handling and trim-on-commit are a nice touch that keeps the persisted map clean.
Summary Table
| Color | Meaning | Verdict effect |
|---|---|---|
| Red | Must fix | Request changes |
| Yellow | Should fix | Request changes |
| Green | Consider | Approve allowed |
Recommendation: Request Changes — driven by [0 Red, 2 Yellow, 2 Green] findings.
dhilgaertner
left a comment
There was a problem hiding this comment.
Code & Security Review
Solid, well-documented feature. Forward-compatible config decode, non-fatal failure handling, a wall-clock timeout on the startup subprocess, and good test coverage. One Yellow asymmetry keeps this from an approve.
Critical Issues
None.
Security Review
Strengths:
- No shell injection:
Process.executableURL+ fixed argument array (["skill","install","--path",target]), never routed through a shell. targetis a fixed, derived path ({devRoot}/.claude/commands/query-corveil.md) — no traversal from user input.- Executable-bit gate (
isExecutableFile) before launch; unset/empty path is a clean no-op.
Concerns:
- The configured
corveilbinary is auto-executed on every launch (skill install) and on demand (--version). This is by design and user-configured, so it's acceptable — worth being aware that a tamperedconfig.jsonbecomes code execution at launch, but that's already true of the devRoot/agent config surface. (Green)
Code Quality
- 🟡
runCorveilVersion/verifyCorveil(SettingsView.swift) has no timeout, unlike the Scaffolder path.installCorveilSkilldeliberately bounds a hung binary withcorveilInstallTimeout(5s + SIGTERM), with an explicit comment that a hung corveil binary must not block. The Verify button runs the same class of user-configured binary viaproc.waitUntilExit()with no timeout. If the binary hangs on--version, the detachedTaskand child process leak indefinitely and the button is stuck on "Verifying…" with no recovery. It's off the main actor so the UI doesn't freeze, but the inconsistency is a real "should fix." Consider reusing the same poll-with-deadline +terminate()pattern. (Yellow) - 🟡 Latent pipe-buffer deadlock in
runCorveilVersion. stdout/stderr are read afterwaitUntilExit(). For--versionthe output is tiny, but a misconfigured binary that writes >64KB before exiting fills the pipe buffer, blocks on write, and — with no timeout (above) — hangs forever. Draining concurrently (or via the timeout loop) closes both gaps at once. (Yellow) - 🟢 In
Scaffolder.installCorveilSkill, stdout is an undrainedPipe(); a chatty corveil binary could fill the buffer and block, but the 5s timeout bounds it to a warning. Functionally safe; considerFileHandle.nullDeviceto make the discard explicit and deadlock-proof. (Green) - 🟢
onRescaffoldusestry?; ifscaffoldthrows,resultisniland theelse if result != nilbranch is skipped, so a stale warning from a prior launch persists across a failed rescaffold. Minor. (Green) - 🟢 First-time-setup double-run avoidance (
corveilBinaryPath: nilincompleteSetup, real path inlaunchMainApp) is correctly reasoned — verifiedcompleteSetupcallslaunchMainApp()right after. (Green)
Tests
- CrowCore
binariesRoundTrip/binariesDefaultsEmptyWhenKeyMissingpass locally; forward-compat decode is well covered. - CrowUI
RunCorveilVersionTestscouldn't run locally (missing GhosttyKit xcframework binary), but the assumptions hold on macOS — verified/bin/echo --version→--versionexit 0,/usr/bin/trueexit 0,/usr/bin/falseexit 1.
Summary Table
| Color | Meaning | Verdict effect |
|---|---|---|
| Red | Must fix | Request changes |
| Yellow | Should fix | Request changes |
| Green | Consider | Approve allowed |
Recommendation: Request Changes — driven by [0 Red, 2 Yellow, 3 Green] findings. The two Yellows are the same root cause: the Verify path lacks the timeout/pipe handling the startup path already has. Add a bounded timeout + concurrent drain to runCorveilVersion and this is an approve.
dhilgaertner
left a comment
There was a problem hiding this comment.
Code & Security Review
Solid, well-documented feature: a generic defaults.binaries map, a Settings picker for the corveil slot, and a per-launch corveil skill install with non-fatal warning surfacing. Forward-compat decoding is tested, the timeout/SIGTERM handling is thoughtful, and the <X>Warning banner pattern matches existing conventions. go/swift build couldn't run here (the workspace's GhosttyKit.xcframework binary artifact is absent in this checkout — environment, not this PR), but CrowCore tests pass including the new binariesRoundTrip / binariesDefaultsEmptyWhenKeyMissing cases.
Two issues, both in subprocess pipe handling.
Security Review
Strengths:
- No injection surface:
Process.argumentsis an argv array (no shell), and the binary path is user-chosen via NSOpenPanel / explicit text entry — not attacker-controlled. isExecutableFilegate before launch; failures are non-fatal and never block startup.- No secrets logged; stderr is truncated to a single line for display.
Concerns: none security-blocking.
Code Quality
-
🟡
Scaffolder.installCorveilSkillleavesstderras an undrainedPipe()— the exact deadlock the code's own comment claims to avoid.Sources/Crow/App/Scaffolder.swift: stdout is routed toFileHandle.nullDevicewith the comment "routing to /dev/null is deadlock-proof — an undrainedPipe()would block the child if it ever wrote >64KB before exit." Butstderris a plainPipe()that is only read viareadToEnd()after the process exits. If corveil writes more than the ~64KB pipe buffer to stderr before exiting, the child blocks on the full pipe, never exits, and you hit the 5s timeout + SIGTERM — and sincescaffold(...)runs on the main thread duringapplicationDidFinishLaunching, that's a 5s stall before first paint. The Verify path (runCorveilVersion) already solves this correctly by draining both pipes concurrently viaDataAccumulator; the install path should do the same (or accept that stderr can be lost). It's bounded by the timeout and a >64KB stderr fromskill installis unlikely, hence yellow not red — but it directly contradicts the stated invariant and is the inconsistency most worth closing. -
🟡
runCorveilVersionsnapshots the accumulators with no EOF barrier — a tail chunk delivered exactly at process exit can be dropped.Packages/CrowUI/Sources/CrowUI/SettingsView.swift: once the poll loop observesproc.isRunning == false, it immediately callsoutAcc.snapshot()/errAcc.snapshot(), thendefernils the readability handlers. The handlers run on a Foundation-managed background queue with no happens-before relationship to the polling thread, so the final readable event delivered at EOF may not have been appended yet when you snapshot (and nilling the handler then drops it). In practice the 50ms poll slack makes this near-deterministic for tiny--versionoutput (why the exact-equality tests pass), but it's technically racy and could intermittently truncate or blank the result. Draining any remaining data synchronously after exit (e.g.readDataToEndOfFile()on each handle before nilling handlers) would make it deterministic.
Notes (non-blocking)
- 🟢 The 5s main-thread block in
installCorveilSkillon a misconfigured/hung binary delays first paint. It's documented and bounded, and the Verify path runs off-actor viaTask.detached; consider mirroring that here if startup latency ever matters. - 🟢 Persistence on the corveil
TextFieldonly fires via.onSubmit/Browse (not per keystroke) — consistent with the existing devRoot field, just noting typing + closing Settings without Enter won't persist.
Summary Table
| Color | Meaning | Verdict effect |
|---|---|---|
| Red | Must fix | Request changes |
| Yellow | Should fix | Request changes |
| Green | Consider | Approve allowed |
Recommendation: Request Changes — driven by [0 Red, 2 Yellow, 2 Green] findings. Both yellows are pipe-drain consistency fixes; addressing the stderr drain in installCorveilSkill to match the Verify path closes the strongest one.
dhilgaertner
left a comment
There was a problem hiding this comment.
Code & Security Review
Nicely structured change — generic defaults.binaries map with forward-compatible decoding, fixed-array Process args (no shell, so no command injection), and a non-fatal warning surfaced through the existing <X>Warning pattern. The pipe-drain/timeout work is thoughtful and well-commented. One concurrency bug in the shared PipeDrainer keeps this from approval.
Critical Issues
None blocking, but see the Yellow below — it can crash app startup.
Code Quality
🟡 PipeDrainer.abandon() can raise an uncaught exception → startup crash — Scaffolder.swift:705 / SettingsView.swift:368
abandon() closes pipe.fileHandleForReading, the same handle the background drain thread is reading via readDataToEndOfFile(). On the proc.run()-throws path this is reachable and largely deterministic:
installCorveilSkillgates onfm.isExecutableFile(atPath:), but that only checks the exec bit — a file with+xthat isn't a launchable image (bad shebang, non–Mach-O, ENOEXEC) still passes the gate and makesproc.run()throw (Scaffolder.swift:606-612,SettingsView.swift:262-268).- In the catch,
abandon()runs synchronously and immediately — typically before the GCD global-queue closure has even been scheduled. The closure then callsreadDataToEndOfFile()on an already-closedFileHandle, which raisesNSFileHandleOperationException("Bad file descriptor"). That's an Obj-C exception Swift can't catch → crash. - Even when the read is already in flight, yanking its fd out from under the blocked
read()is undefined and can raise the same way.
Scaffolder runs inside applicationDidFinishLaunching, so a mis-pointed corveil path turns into a crash on launch rather than the intended orange banner.
Fix: have abandon() close the write end instead of the read end:
func abandon() {
try? pipe.fileHandleForWriting.close()
}Closing the write end delivers a clean EOF, so readDataToEndOfFile() returns normally and signals the semaphore. In the launch-failure case the parent still owns the only write end (the child never spawned), so this reliably unblocks the reader; in the timeout case Process has already closed the parent's write copy, so it's a harmless no-op. Applies to both copies of the helper.
🟢 Main thread blocks up to ~5.5s on a hung corveil — Scaffolder.swift:618-633
The poll-loop + 0.5s SIGTERM grace runs on the main thread before first paint (acknowledged in the comments). 5s is generous, but a hung binary still delays window draw by the full budget. Consider running the install off-main and updating corveilSkillInstallWarning when it lands, so a misbehaving corveil never delays launch at all. Defensible as-is given the timeout bound.
🟢 Trim inconsistency — Scaffolder.swift:568 trims .whitespaces; the UI binding (SettingsView.swift:204) trims .whitespacesAndNewlines. Harmless, but aligning them avoids a path that round-trips differently than it's validated.
🟢 Duplicated PipeDrainer/DataAccumulator across Scaffolder (Crow target) and SettingsView (CrowUI). Already acknowledged in the comments; fine for now, but if a third caller appears a small CrowCore utility would pay off — and would mean the fix above only lands once.
Security Review
Strengths:
Process.argumentsis a fixed array — no shell interpolation, no command injection via path or devRoot.isExecutableFilegate before exec; failures are non-fatal and never block startup.- Forward-compatible config decode (
decodeIfPresent ... ?? [:]) — pre-CROW-482 configs still load.
Concerns:
- 🟢 The feature executes a user-configured binary on every launch. That's the intended design and the threat model is local (an attacker who can write
config.jsonalready has code-exec footing), so no change needed — just noting the launch-time exec surface.
Tests
- CrowCore
binariesRoundTrip/binariesDefaultsEmptyWhenKeyMissingpass. - The CrowUI/Crow targets don't compile in this review checkout (missing
Frameworks/GhosttyKit.xcframeworkbinary artifact — unrelated to this PR), so therunCorveilVersionsuite andswift buildweren't executed here. The PR notes a cleanswift build --arch arm64locally.
Summary Table
| Color | Meaning | Verdict effect |
|---|---|---|
| Red | Must fix | Request changes |
| Yellow | Should fix | Request changes |
| Green | Consider | Approve allowed |
Recommendation: Request Changes — driven by [0 Red, 1 Yellow, 4 Green] findings. The Yellow abandon() fix is small and worth landing in this round; the Greens are optional.
|
@dhilgaertner — round-4 review landed against So the current code (HEAD
Trade-off vs the round-3 bg-drain request: a corveil binary writing >64KB to stderr before exit would now block on a full pipe buffer until the watchdog SIGTERMs it, losing the tail. Documented in-comment. Real corveil emits <1KB and the bg-drain approach turned out not to deliver EOF reliably in CI anyway, so the simpler pattern won. Ready for re-review against |
Adds a `defaults.binaries` map to config.json (keyed by tool name) and a Settings → General → "Corveil CLI" section so the user can point Crow at their locally-built corveil binary. On every launch, the Scaffolder runs `<corveilPath> skill install --path <devRoot>/.claude/commands/query-corveil.md` so the embedded /query-corveil slash command stays in sync with the user's corveil without Crow shipping a (drifting) copy of the skill body. Failures here are non-fatal: an unset path is a no-op, a missing/ non-executable binary or non-zero install exit logs and surfaces a transient banner in Settings via the existing `appState.<X>Warning` pattern. Includes a "Verify" affordance that runs `<path> --version` off the main actor and inlines the result. Closes #482 🐦⬛ Generated with Claude Code, orchestrated by Crow Co-Authored-By: Claude <[email protected]> Crow-Session: 8E1A62FA-5A6E-4F07-948F-8B6F379F8FE3
- Bound the per-launch corveil install with a 5s wall-clock timeout (`Scaffolder.corveilInstallTimeout`). `scaffold(...)` runs on the main thread during applicationDidFinishLaunching, so a hung corveil binary would freeze startup before the window draws. Poll for completion in 50ms slices, SIGTERM on timeout, and return a warning instead of blocking forever. - Stop running the install twice on first-time setup. `completeSetup` scaffolds with `corveilBinaryPath: nil`, then `launchMainApp` (which it calls immediately after) does the real install — previously both ran the subprocess and only the second result was kept. - Add round-trip + missing-key tests for `defaults.binaries` matching the convention used by the adjacent `ignoreReviewLabels` tests. - Add five tests for `SettingsView.runCorveilVersion(at:)` covering the non-executable gate, empty input, the `✓ Verified` formatting when a successful binary has no output, the exit-code formatting on failure, and a successful binary that emits stdout. 🐦⬛ Generated with Claude Code, orchestrated by Crow Co-Authored-By: Claude <[email protected]> Crow-Session: 8E1A62FA-5A6E-4F07-948F-8B6F379F8FE3
- Bound `SettingsView.runCorveilVersion` with a 5s wall-clock timeout (`verifyTimeout`) using the same poll-with-deadline + SIGTERM pattern as the Scaffolder install path. A user-configured corveil that hangs on `--version` can no longer pin the detached Task or leak the child process indefinitely. - Drain stdout/stderr concurrently via readability handlers so a binary emitting >64KB before exit can't pipe-buffer-deadlock against an unread `Pipe()`. Accumulators go through a small `DataAccumulator` reference type with NSLock-serialized access — safe under Swift 6 sendable rules because the closure captures the class, not a `var`. - `installCorveilSkill` now uses `FileHandle.nullDevice` for stdout instead of an undrained `Pipe()`. Explicit discard, deadlock-proof even if the 5s install timeout weren't there. - `onRescaffold` does a `do/catch` instead of `try?` so a thrown scaffold failure replaces (not silently preserves) any stale corveil-install banner from a prior launch. 🐦⬛ Generated with Claude Code, orchestrated by Crow Co-Authored-By: Claude <[email protected]> Crow-Session: 8E1A62FA-5A6E-4F07-948F-8B6F379F8FE3
Both pipe-handling paths now use a shared `PipeDrainer` pattern: a dedicated background thread runs `readDataToEndOfFile()` on the read end and signals a `DispatchSemaphore` on EOF. The caller waits on the semaphore (capped at `pipeDrainGrace` = 1s) before snapshotting, which establishes a happens-before barrier between the drain's final append and the read — no tail chunks dropped, no readability-handler race. - `Scaffolder.installCorveilSkill` drains stderr concurrently rather than leaving it as an undrained `Pipe()`. Closes the deadlock gap the existing comment on stdout's nullDevice claimed to avoid: a chatty failure (Go panic, assertion dump) can easily exceed the ~64KB pipe buffer. - `SettingsView.runCorveilVersion` replaces the readability-handler pattern with `PipeDrainer.start(...)` per pipe. The bounded semaphore wait is deterministic — no race between the polling thread observing `proc.isRunning == false` and an in-flight handler that may still hold the final tail chunk. `PipeDrainer` + `DataAccumulator` are duplicated across the Crow target (Scaffolder) and CrowUI (SettingsView) since they don't share a module today; both copies are ~25 lines each, which beats adding a CrowCore utility for two callers. 🐦⬛ Generated with Claude Code, orchestrated by Crow Co-Authored-By: Claude <[email protected]> Crow-Session: 8E1A62FA-5A6E-4F07-948F-8B6F379F8FE3
dhilgaertner
left a comment
There was a problem hiding this comment.
Code & Security Review
Critical Issues
None.
Security Review
Strengths:
- No shell injection. Both subprocess call sites (
Scaffolder.installCorveilSkill,SettingsView.runCorveilVersion) useProcesswithexecutableURL+ anargumentsarray — never a shell string. The user-supplied path and--pathtarget are passed as discrete argv entries, so paths with spaces/metacharacters can't inject commands. - Defensive pre-exec gate.
FileManager.isExecutableFile(atPath:)is checked beforeproc.run()in both paths, so a missing/non-executable path degrades to a warning rather than a launch error. - Inherent trust boundary is appropriate. Executing a user-picked binary is the explicit purpose of the feature (a local dev tool the user built), not an escalation — no untrusted input crosses a boundary.
- Warning surfacing is safe. corveil's stderr is shown in a
textSelection-enabled banner; it's the user's own machine output, no secret exposure, and it's truncated to a single line. - Forward-compatible config decode.
binariesusesdecodeIfPresent ?? [:], andAppConfigTests.binariesDefaultsEmptyWhenKeyMissinglocks in that pre-CROW-482 configs still decode. Verified passing locally (262 CrowCore tests green).
Code Quality
- Clean two-way
corveilBindingthat treats empty as "unset" (no stale empty map entries) and trims on commit — and301bb5aaligns the Scaffolder trim charset (.whitespacesAndNewlines) with it. - First-launch double-install is correctly avoided:
completeSetuppassescorveilBinaryPath: nil, andlaunchMainApp(always invoked right after) re-scaffolds with the real path. Verified the control flow at AppDelegate.swift:306 → :318 → :402. - Warning lifecycle is handled well —
onRescaffoldalways assignsresult.warning(clearing a stale banner on later success) and replaces it with a fresh message in thecatch. - Verify runs off the main actor via
Task.detachedwith mutations marshaled back throughMainActor.run; the button is disabled while in-flight. UI stays responsive.
Consider (Green — non-blocking)
- Watchdog uses SIGTERM only.
TimeoutWatchdog/ScaffolderTimeoutWatchdogcallproc.terminate()(SIGTERM) with no SIGKILL escalation. SinceScaffolder.scaffoldruns synchronously on the main thread duringapplicationDidFinishLaunching, a pathological binary that ignores SIGTERM would block first paint indefinitely. Real-world risk is very low (askill installcommand terminating on default SIGTERM disposition), but a SIGKILL fallback after a short grace window would make the startup path provably non-hanging. - Main-thread cost on every launch. Even in the happy path, configured users pay subprocess spawn+exec latency before the window draws (bounded at
corveilInstallTimeout= 5s). The tradeoff (skill synced before the Manager session starts) is reasonable and thoroughly documented; flagging only so it's a conscious choice. - Narrow false-timeout window. If the child exits at almost exactly the deadline, the watchdog handler could set
didFirebetweenwaitUntilExit()returning andcancel()reading it, mislabeling a success as a timeout. Microsecond window, cosmetic-only impact. - Watchdog duplicated across two targets. Author documented the rationale (Scaffolder and SettingsView are separate targets with no shared private util); two small copies is a defensible call.
Verification notes
swift testinPackages/CrowCorepasses (262 tests), including both newbinariesround-trip/forward-compat tests.- The main
Crow/CrowUItarget could not be built here: the prebuiltGhosttyKit.xcframeworkcontains no binary artifact in this checkout (environment limitation, unrelated to this PR). TheSettingsView.swift/Scaffolder.swiftchanges and therunCorveilVersionUI tests were therefore reviewed by inspection; the PR author reportsswift build --arch arm64clean.
Summary Table
| Color | Meaning | Verdict effect |
|---|---|---|
| Red | Must fix | Request changes |
| Yellow | Should fix | Request changes |
| Green | Consider | Approve allowed |
Recommendation: Approve — driven by [0 Red, 0 Yellow, 4 Green] findings. Well-engineered, well-documented, tests pass; the Green items are optional hardening/awareness notes the author can weigh.
CI test `zeroExitWithStdoutShowsSnippet` failed: `/bin/echo --version` produced an empty captured snippet, returning "✓ Verified" instead of "✓ --version". Root cause: Foundation's `Process` only closes the parent's copy of a `Pipe`'s write FD as part of `waitUntilExit()`. Both the install path (`Scaffolder.installCorveilSkill`) and the Verify path (`SettingsView.runCorveilVersion`) use a polling loop on `proc.isRunning` instead of `waitUntilExit()`, so the parent's writeFD stayed open. EOF never arrived on the read end, the drain thread's `readDataToEndOfFile()` blocked forever, and the snapshot returned empty after the 1-second `pipeDrainGrace` expired. The `true`/`false` test cases passed accidentally because empty captured output happens to produce the expected string for those exit codes. The echo case exposed the bug because it depended on captured stdout. Fix: after `proc.run()` succeeds, explicitly close the parent's copies of the pipe write ends so EOF is delivered when the child exits. The child already has its own dup'd FD, so closing the parent's copy is safe. 🐦⬛ Generated with Claude Code, orchestrated by Crow Co-Authored-By: Claude <[email protected]> Crow-Session: 8E1A62FA-5A6E-4F07-948F-8B6F379F8FE3
The previous fix (closing `outPipe.fileHandleForWriting` in the parent) was insufficient — CI continued to fail with the same empty captured-stdout symptom on `/bin/echo --version`. Root cause: Foundation's `Process` keeps an **internal** copy of each pipe's write FD (separate from the `Pipe.fileHandleForWriting` we expose), and only releases it as part of `waitUntilExit()`'s cleanup. Closing the exposed handle drops one reference but leaves Foundation's own copy open, so the pipe still has a live writer and EOF never reaches the bg drain's `readDataToEndOfFile()`. Fix: after the polling loop has observed `proc.isRunning == false`, call `proc.waitUntilExit()`. The wait itself is a no-op at that point — the child is already gone — but the cleanup side effect releases Foundation's internal FD copy so EOF is finally delivered to our read ends and the drains complete with the captured bytes. Verified locally: all 5 `SettingsView.runCorveilVersion` tests pass. `zeroExitWithStdoutShowsSnippet` (which depends on captured stdout) now correctly returns "✓ --version" for `/bin/echo`. 🐦⬛ Generated with Claude Code, orchestrated by Crow Co-Authored-By: Claude <[email protected]> Crow-Session: 8E1A62FA-5A6E-4F07-948F-8B6F379F8FE3
The bg-drain + polling pattern was unreliable on CI: `zeroExitWithStdoutShowsSnippet` continued to fail with empty captured stdout from `/bin/echo --version` even after calling `waitUntilExit()` after the polling loop. Calling `waitUntilExit` post-poll is too late — polling consumed the exit observation, and Foundation's pipe-FD cleanup runs as a side effect of `waitUntilExit` blocking on the process, not as cleanup after the fact. The reliable pattern: install a `TimeoutWatchdog` (DispatchSourceTimer that SIGTERMs the child on expiry), then call `proc.waitUntilExit()` synchronously. Foundation owns the wait, runs its pipe cleanup, and by the time `waitUntilExit` returns the pipe write FDs are closed. Subsequent `readToEnd()` calls return the captured data immediately. Trade-off (vs the bg-drain approach the round-3 review asked for): a corveil binary that writes >64KB to stderr before exiting would block on a full pipe buffer until the watchdog SIGTERMs it, and we'd lose stderr beyond the buffer. Documented as a known limit — real corveil output is <1KB; the bg-drain approach turned out not to fix this anyway in CI, so the simpler pattern wins. Verified locally: all 5 RunCorveilVersion tests pass in 12ms. 🐦⬛ Generated with Claude Code, orchestrated by Crow Co-Authored-By: Claude <[email protected]> Crow-Session: 8E1A62FA-5A6E-4F07-948F-8B6F379F8FE3
Round-4 review Green: `Scaffolder.installCorveilSkill` trimmed `corveilBinaryPath` with `.whitespaces`, while the SettingsView `corveilBinding` setter trimmed with `.whitespacesAndNewlines`. A config edited by hand to contain a trailing newline would round-trip differently depending on which side processed it. Use the wider character set on both sides. 🐦⬛ Generated with Claude Code, orchestrated by Crow Co-Authored-By: Claude <[email protected]> Crow-Session: 8E1A62FA-5A6E-4F07-948F-8B6F379F8FE3
301bb5a to
f17b1d2
Compare
The launch-time path (#483) installs the embedded /query-corveil skill into the devroot via `corveil skill install`. After rebuilding corveil locally, the embedded skill version drifts from what's installed in the devroot, and the only ways to fix it were heavy: restart Crow (closes every session), switch workspaces, or re-pick the same path in Settings. Add a dedicated "Reinstall skill" button next to the existing Verify button that runs the same install flow on demand. Disabled when the picker is unset; surfaces ✓/✗ inline (coalesced with Verify's result line — only one operation runs at a time); also updates the launch-time warning banner so "is corveil broken?" has a single source of truth. Plumbing: SettingsView lives in the CrowUI package which can't import the Crow app target where Scaffolder lives, so the call goes through a new `AppState.onReinstallCorveilSkill` async closure wired in AppDelegate (matches the existing on* callback pattern). The closure offloads the synchronous Process.waitUntilExit work to a detached task internally so awaiting it from the main actor doesn't pin the UI for the install timeout. `Scaffolder.installCorveilSkill` access is loosened from private to internal so AppDelegate can call it on a fresh Scaffolder instance. Closes #491 🐦⬛ Generated with Claude Code, orchestrated by Crow Co-Authored-By: Claude <[email protected]> Crow-Session: F2C4CB9B-B99D-4C06-AD44-E04F5F5E11A4
## Summary - Adds a **Reinstall skill** button next to the existing **Verify** button in Settings → General → Corveil CLI. - Runs the same `corveil skill install --path …` flow as the per-launch path (#483), on demand — no restart, workspace switch, or path re-pick required. Serves the common "I just rebuilt corveil locally; install the new embedded skill" loop. - Disabled when the picker is unset; shows `✓ Skill reinstalled` / `✗ <diagnostic>` inline (coalesces with Verify's result line — only one runs at a time). A successful manual reinstall also clears the launch-time warning banner; a failed one updates it, so "is corveil broken?" stays single-sourced. ## Plumbing SettingsView lives in the CrowUI package, which cannot import the Crow app target where `Scaffolder` lives. The call therefore goes through a new `AppState.onReinstallCorveilSkill` async closure wired in `AppDelegate.launchMainApp` — same callback-injection pattern as `onCreateManager`, `onRestartManager`, etc. The closure offloads the synchronous `Process.waitUntilExit` work to a detached task internally, so awaiting it from the main actor doesn't pin the UI for the 5-second install timeout. `Scaffolder.installCorveilSkill` access is loosened from `private` to `internal` so `AppDelegate` can call it on a fresh `Scaffolder` instance (this is the "function-accessibility refactor" the ticket references — #490 has not yet landed in this branch). ## Test plan - [ ] Configure a valid `corveil` path → click **Reinstall skill** → expect transient `✓ Skill reinstalled`; `{devRoot}/.claude/commands/query-corveil.md` byte-matches `<corveilPath> skill show` - [ ] Rebuild corveil locally with a tweaked embedded skill string → click **Reinstall skill** again without restarting Crow → expect file content to reflect the new build - [ ] Set path to a non-executable junk value → click → expect `✗ <diagnostic>` inline AND the orange warning banner at the top of General (existing `corveilSkillInstallWarning` surface) - [ ] Successful reinstall after a failure clears the orange banner - [ ] Clear the path → button is disabled with tooltip "Set the Corveil CLI path first." - [ ] Click repeatedly with a valid path → no errors (install is idempotent via `os.WriteFile` + `os.Chmod`) - [ ] Verify button and Reinstall button mutually disable while either is running Closes #491 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude <[email protected]>
Summary
defaults.binariesmap (keyed by tool name) toConfigDefaults, with a Settings → General → "Corveil CLI" picker for thecorveilslot (file-only NSOpenPanel + Verify button that runs<path> --version).Scaffolderinvokes<corveilPath> skill install --path {devRoot}/.claude/commands/query-corveil.mdso the embedded slash command stays in sync with the user's locally-built corveil — no skill source incrow/skills/, corveil owns the content.appState.corveilSkillInstallWarningbanner using the existing<X>Warningpattern (matchesgithubScopeWarning/rateLimitWarning). Unset path = no-op. Non-executable or non-zero exit = log + transient Settings banner, never blocks startup.Closes #482
Test plan
swift build --arch arm64succeeds (verified — clean compile)query-corveil.mdwritten, no banner, no crash.config.jsonunderdefaults.binaries.corveil.✓ corveil version ...line.{devRoot}/.claude/commands/query-corveil.mdexists anddiff <(corveil skill show) <(cat ...)is empty./query-corveilslash command loads in a Claude Code session under that devroot./tmp/not-corveil→ relaunch shows the orange Settings banner; noquery-corveil.mdwritten.🤖 Generated with Claude Code