Skip to content

Protect workspace state from silent overwrites; sandbox tests away from real user data - #451

Merged
jamesrochabrun merged 1 commit into
mainfrom
fix-workspace-state-safety
Aug 13, 2026
Merged

Protect workspace state from silent overwrites; sandbox tests away from real user data#451
jamesrochabrun merged 1 commit into
mainfrom
fix-workspace-state-safety

Conversation

@jamesrochabrun

Copy link
Copy Markdown
Owner

Incident

On 2026-08-13, while the full test gate ran, both session_workspace_state rows in the production session_metadata.sqlite were 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

getWorkspaceStateSync collapsed any read error into empty state:

(try? dbQueue.read {  }) ?? SessionWorkspaceState()

GRDB's default busyMode errors 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). getWorkspaceStateSync remains as display-only sugar.
  • CLISessionsViewModel gates 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.
  • GRDB busyMode = .timeout(5) so transient contention waits instead of erroring.

Test isolation (no suite can reach real state again):

  • New AgentHubApplicationSupport.baseDirectoryURL resolves 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_DIR overrides explicitly (E2E/CLI harnesses).
  • Adopted by SessionMetadataStore's default init and ClaudeHookPaths (approvals/, claims/, hooks/ — both dirs were also being touched by test runs). ContextPayloadStore, ThemeManager, and SimulatorService still build their own paths — follow-up candidates.
  • TerminalProcessRegistry and AgentHubProvider inherit the sandbox automatically via the default init.

Testing

New WorkspaceStateSafetyTests (headless core gate):

  • test processes resolve to the sandbox, never ~/Library/Application Support/AgentHub; default-init store lands there — verified the production sqlite's mtime was untouched by the test run
  • a failed read disables saves: seeded state survives an import + restore cycle byte-identical
  • a successful read enables saves and preserves restored state

Plus targeted neighbors: LaunchRestoreSessionRetentionTests, ClaudeHookInstallStateStoreTests, AgentWorkspacePersistenceTestsall passed (15/15 across the run).

CLAUDE.md now documents both invariants under Database Migration Rules.

🤖 Generated with Claude Code

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.
@jamesrochabrun
jamesrochabrun merged commit 39b7e0c into main Aug 13, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant