feat: Workspace filesystem cleanup #391
12 issues
Medium
Build Logs path not updated to workspace-scoped layout - `src/snapshot-tests/__fixtures__/cli/simulator/test--failure.txt:100`
The Derived Data path was updated to the new workspace-scoped layout (workspaces/XcodeBuildMCP-<HASH>/DerivedData/...), but the Build Logs path on the final line still points to the old top-level XcodeBuildMCP/logs/ directory. If the workspace cleanup refactor moved logs under the workspace-keyed path (as Derived Data was), this fixture is internally inconsistent and may indicate MCP/CLI/JSON fixture drift. If logs intentionally remain at the top level, ignore.
Test count and failure list change unrelated to workspace path refactor - `src/snapshot-tests/__fixtures__/mcp/simulator/test--failure.txt:9-35`
This fixture update mixes a workspace-scoped Derived Data path change (the stated intent of the PR) with a substantive change in observed test results: the discovered test count increases from 52 to 57 and failures grow from 2 to 4, adding new Swift Testing-style entries. The skill's guardrails require that fixture updates map to intentional behavior changes and that updates not be made solely to make tests pass. The PR description only mentions workspace-scoped path churn, so the additional test discovery/failure deltas need to be justified as an intentional behavior change or reverted; otherwise the fixture may be masking an unrelated regression or test-suite drift.
Forced-shutdown timeout is never cleared, causing concurrent cleanup and wrong exit code - `src/daemon.ts:344-351`
The setTimeout at line 344 is never cancelled when server.close() completes successfully. If the server closes (line 334) but the subsequent cleanupArtifacts() + flushAndCloseSentry(2000) chain takes longer than 5 seconds (the Sentry flush alone allows up to 2s), the timeout fires and invokes cleanupArtifacts() a second time concurrently with the in-flight cleanup, then calls process.exit(1). This can (a) race two cleanup runs against the same workspace-keyed artifacts/locks, and (b) override the intended exitCode with 1, masking clean shutdowns as failures.
Also found at:
src/cli/daemon-control.ts:53-57
Daemon socket moved to shared tmpdir without guaranteed directory permissions - `src/daemon/socket-path.ts:21-36`
daemonRunDir() now returns os.tmpdir(), and daemonDirForWorkspaceKey() places the daemon socket under tmpdir()/xcodebuildmcp-<hash>/d.sock. On Linux this resolves to /tmp, which is world-writable and shared across users. The socket directory is created in ensureSocketDir() with mkdirSync({recursive: true, mode: 0o700}), but recursive mkdir does not change permissions on a pre-existing directory. A local attacker who guesses or enumerates the workspace hash can pre-create /tmp/xcodebuildmcp-<hash> with looser permissions before the daemon starts; the daemon will then bind its socket inside an attacker-controlled directory, enabling socket hijacking, MITM of daemon RPC, or command injection through the daemon protocol. The previous implementation placed daemon artifacts under ~/.xcodebuildmcp (line 5 of the pre-diff), which inherited the home directory's restrictive permissions and was not subject to this race.
Also found at:
src/daemon/socket-path.ts:33-36
Daemon registry lock can leak if startup throws before server.listen callback - `src/daemon.ts:186-197`
The startup registry lock is acquired at line 186, but is only released in three places: (1) the explicit early-exit when another daemon is detected after lock (line 201), (2) the server.on('error', releaseStartupRegistryLock) handler registered at line 412, and (3) the finally block inside the server.listen callback at line 433. If any synchronous or asynchronous code between lock acquisition and server.listen (e.g. buildDaemonToolCatalogFromManifest, loadManifest, startDaemonServer) throws, the lock is never released. The top-level main().catch handler calls process.exit(1) but does not invoke releaseStartupRegistryLock, which can leave a stale lock file blocking subsequent daemon startups for the same workspace.
Low
isPidAlive treats pid<=0 as live due to process.kill process-group semantics - `src/utils/process-liveness.ts:1-8`
process.kill(0, 0) and process.kill(-N, 0) have special semantics on Unix: pid 0 targets the caller's process group and negative pids target a process group, which will typically succeed and cause isPidAlive to return true for invalid/sentinel pids. Some callers (e.g. src/daemon/daemon-registry.ts:219, src/utils/fs-lock.ts:93, src/utils/log-capture/simulator-launch-oslog-sessions.ts:85) do not pre-validate pid > 0, so a corrupted or zero-valued pid record would be considered live and prevent cleanup of stale artifacts — undermining the workspace cleanup ownership boundaries this PR introduces.
scheduleWorkspaceFilesystemLifecycleSweep records schedule timestamp before checking running set - `src/utils/workspace-filesystem-lifecycle.ts:411-419`
In scheduleWorkspaceFilesystemLifecycleSweep, lastScheduledAtByScope and lastScheduledAtByPreKey are only updated after the runningScheduledSweeps.has(scheduleKey) early return. That part is fine, but if a sweep is already running, subsequent calls will keep hitting the running-set early return without ever updating the cooldown timestamp; once the running sweep finishes, the next call will schedule another sweep immediately because the cooldown was never advanced. This can cause back-to-back sweeps under load instead of the intended cooldown spacing.
cleanupOwnedWorkspaceFilesystemArtifacts never prunes logs and bypasses lock/cooldown - `src/utils/workspace-filesystem-lifecycle.ts:478-504`
cleanupOwnedWorkspaceFilesystemArtifacts returns a result with scanned: 0, deleted: 0 and does not invoke pruneKnownLogDirectory or acquire the workspace fs lock. If the caller relies on this shutdown path to also enforce log retention (as the PR description implies), expired/excess logs will accumulate because shutdown skips the prune step entirely. Additionally, daemon cleanup runs without the shared lock, which could race with another process performing a sweep.
Pre-key cooldown check uses caller-supplied now without resolveOptions normalization - `src/utils/workspace-filesystem-lifecycle.ts:395-405`
buildSchedulePreKey/pre-key cooldown uses options.now ?? Date.now() before resolveOptions runs. If a caller passes a now that resolveOptions would normalize differently (e.g., default), the pre-key cooldown comparison can disagree with the post-resolve scope cooldown comparison, allowing a sweep to be scheduled that would otherwise be cooled down (or vice versa). The two cooldown gates can produce inconsistent decisions for the same call.
runStartupLifecycleSweep defined but not invoked in hunk - `src/daemon.ts:152-171`
The hunk defines a runStartupLifecycleSweep arrow function assigned to a const, replacing the inline try/catch that previously executed immediately. If this function is intended to run at startup, defining it as a callable abstraction without an immediate invocation adds an unnecessary layer of indirection compared to the original direct execution. Per the skill's guidance to avoid redundant abstractions and prefer direct, explicit code, inlining the lifecycle sweep (or invoking it immediately at the definition site) would be simpler than defining a single-use function unless it is awaited later in the file.
...and 2 more
14 skills analyzed
| Skill | Findings | Duration | Cost |
|---|---|---|---|
| xcodebuildmcp-docs-release-review | 0 | 2m 17s | $0.02 |
| xcodebuildmcp-docs-command-review | 0 | 2m 15s | $0.02 |
| xcodebuildmcp-runtime-boundary-review | 0 | 4m 55s | $0.19 |
| xcodebuildmcp-snapshot-fixture-review | 2 | 2m 18s | $4.09 |
| xcodebuildmcp-structured-output-review | 0 | 5m 31s | $0.95 |
| xcodebuildmcp-test-boundary-review | 0 | 4m 43s | $3.07 |
| xcodebuildmcp-tool-contract-review | 0 | 2m 25s | $0.18 |
| wrdn-pii | 0 | 8m 39s | $5.75 |
| wrdn-authz | 0 | 10m 11s | $1.46 |
| wrdn-code-execution | 0 | 9m 33s | $1.35 |
| wrdn-data-exfil | 0 | 10m 59s | $1.36 |
| find-bugs | 6 | 14m 45s | $4.47 |
| code-review | 1 | 11m 59s | $2.74 |
| code-simplifier | 3 | 12m 41s | $2.74 |
Duration: 103m 11s · Tokens: 13.2M in / 45.9k out · Cost: $28.45 (+extraction: $0.00, +merge: $0.01, +dedup: $0.05)