Protect workspace state from silent overwrites; sandbox tests away from real user data - #451
Merged
Merged
Conversation
On 2026-08-13 a test run sharing the production database led the app to overwrite session_workspace_state with a near-empty list, losing the user's tracked projects and monitored sessions. Two layers of defense: Product fix (reachable by real users — locked DB, app killed mid-launch): - SessionMetadataStore.readWorkspaceState(for:) is a throwing read that distinguishes "no saved row" from a failed read; getWorkspaceStateSync is now display-only sugar over it. - CLISessionsViewModel disables all workspace-state saves until one successful read per run (canPersistWorkspaceState), with a 3-attempt retry. A failed read can no longer masquerade as empty state and get saved back over the user's data. - GRDB busyMode is now .timeout(5) so transient cross-process lock contention waits instead of erroring into the empty-state path. Test isolation (no suite can touch real user state again): - New AgentHubApplicationSupport.baseDirectoryURL resolves persistent state paths. Test processes (XCTest loaded, incl. app-as-test-host) are sandboxed into a per-process temp dir; AGENTHUB_APP_SUPPORT_DIR overrides explicitly. Adopted by SessionMetadataStore's default init and ClaudeHookPaths (approvals/claims/hooks). New WorkspaceStateSafetyTests pin both layers; CLAUDE.md documents the invariants.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Incident
On 2026-08-13, while the full test gate ran, both
session_workspace_staterows in the productionsession_metadata.sqlitewere overwritten with a near-empty list — the user's sidebar lost all tracked projects, and re-adding a folder showed no sessions (the monitored-session IDs live in the same row). Session files on disk were never touched; the app's own persistence wrote a blank slate over good data.Root cause
getWorkspaceStateSynccollapsed any read error into empty state:GRDB's default
busyModeerrors immediately under cross-process lock contention, so a concurrent process (here: a test runner; for real users: a second app instance, a killed-mid-launch relaunch, any transient I/O failure) makes the read fail → restore sees "empty" → the next save persists that emptiness over the user's real state.agent_workspaces-driven restore then re-added the one workspace project, which is exactly the[Canvas]row observed.Fix — two independent layers
Product (protects real users):
SessionMetadataStore.readWorkspaceState(for:)— throwing read that distinguishes "no saved row" (empty state) from a failed read (throws).getWorkspaceStateSyncremains as display-only sugar.CLISessionsViewModelgates all workspace-state saves behind one successful read per run (canPersistWorkspaceState), with a 3-attempt retry at launch. If the read never succeeds, saves stay disabled for the entire run — losing one run's worth of tracking changes instead of the user's data.busyMode = .timeout(5)so transient contention waits instead of erroring.Test isolation (no suite can reach real state again):
AgentHubApplicationSupport.baseDirectoryURLresolves all persistent-state paths. Any process with XCTest loaded — package runners and the app running as a unit-test host — is sandboxed into a per-process temp directory;AGENTHUB_APP_SUPPORT_DIRoverrides explicitly (E2E/CLI harnesses).SessionMetadataStore's default init andClaudeHookPaths(approvals/,claims/,hooks/— both dirs were also being touched by test runs).ContextPayloadStore,ThemeManager, andSimulatorServicestill build their own paths — follow-up candidates.TerminalProcessRegistryandAgentHubProviderinherit the sandbox automatically via the default init.Testing
New
WorkspaceStateSafetyTests(headless core gate):~/Library/Application Support/AgentHub; default-init store lands there — verified the production sqlite's mtime was untouched by the test runPlus targeted neighbors:
LaunchRestoreSessionRetentionTests,ClaudeHookInstallStateStoreTests,AgentWorkspacePersistenceTests— all passed (15/15 across the run).CLAUDE.md now documents both invariants under Database Migration Rules.
🤖 Generated with Claude Code