Files
DeepSeek-Reasonix/docs/desktop-migration
esengine 51f6a0ad6d fix(desktop): restore each canonical session's own permission preset
Root cause: canonical (v5) sessions had nowhere to keep their permission
preset. The legacy BranchMeta sidecar is refused for them, and neither the
Session Service projection nor workspace presentation carries a preset, so
the value lived only on the tab. OpenSession applied the source surface's
live preset to the target session (prepareCanonicalControllerRuntime read
it from the outgoing controller). Session B inherited A's
danger-full-access, and a surface built from New Chat forced workspace-write
onto every session opened into it. The renderer had the same leak one layer
up: its composer profile table is keyed by tab, and the push that follows a
navigation sent the previous session's preset to the new one.

Decision: keep the preset per canonical SessionID in a desktop-owned record
(desktop-session-presets.json next to desktop-tabs.json), with no change to
the session log schema. Opening a session, and restoring tabs at startup,
use only the value recorded for that exact SessionID. A session with no
record gets the new-session default. The value on the surface is never
carried over.

Recording rule: a record is written only by SetPermissionPresetForTab, the
one preset setter fenced by session. The caller passes the SessionID of the
permission snapshot it read; under the tab's turnStartMu, which also
serializes navigation, the call is refused with
reasonix_error:permission_session_changed (errPermissionSessionChanged)
unless that id equals the live controller's session. The record is keyed by
that same session and the file is written outside App.mu. The revision alone
cannot fence this: it is per controller, so two freshly opened sessions share
a number. SetToolApprovalModeForTab and SetModeForTab name no session and so
never record; saving tabs, submissions, composer-profile sync and draft
settings never record either.

Fix:
- sessionPresetStore exposes restore, record and forget. A failed write
  removes the file, or empties it in place when the directory refuses the
  removal, so a restart opens every session at the default; the full set is
  written again on the next record or forget.
- The renderer passes the snapshot's sessionId to SetPermissionPresetForTab.
  On permission_session_changed it re-reads the tab and does not retry the
  choice on the session the tab now shows.
- Whenever a tab's canonical SessionID changes (syncTabSessionIdentity:
  New Session, clear, legacy continue, rebuild), the tab and its controller
  take that session's own record or the new-session default.
- New Session on a blank tab that is reused drops the blank session's record,
  resets it to the default, and advances the controller's permission
  revision (Controller.InvalidatePermissionSnapshots), so a snapshot read
  before the reuse cannot record a choice for the session handed out as new.
- A fork tab starts at the new-session default rather than the source tab's
  preset.
- The canonical open path passes the target session's restored preset to
  prepareCanonicalControllerRuntime, and startup restore does the same for
  tabs bound to a SessionID. Legacy tabs keep their existing sidecar path.
  A session that is permanently deleted drops its record.
- docs/desktop-migration inventory regenerated for the new signature and
  line positions.
- Composer profiles in the renderer record which session they were read for
  (owner). A profile, or a meta snapshot, whose owner belongs to a different
  session than the tab's current one is discarded, not reconciled.

Tests:
- TestCanonicalSessionPresetFollowsSessionAcrossNavigationAndRestart and
  TestRestartDoesNotTrustSurfacePresetForUnrecordedSession fail on
  origin/main-v2 (session-B / session-A tab preset = "danger-full-access",
  want "workspace-write").
- TestNewSessionInTabDoesNotInheritSourcePreset: A=full, New Session in the
  same tab -> the new session is the default, has no record, and stays the
  default after restart through desktop-tabs.json and after reopening; A is
  still full.
- TestNewSessionOnBlankTabDropsItsEarlierChoice and
  TestForkStartsAtNewSessionDefault cover the reused blank and fork paths.
  These three fail without the recording rule above with
  tab preset = "danger-full-access", want "workspace-write".
- TestUnreadablePresetRecordsRestoreDefault: a corrupt record file restores
  the default.
- TestStalePresetChoiceDoesNotLandOnNavigatedSession: snapshot A, open B
  (same revision), then set full access with A's snapshot -> refused with
  errPermissionSessionChanged, B not recorded, B stays at the default. The
  same scenario through the unfenced signature fails with "stale choice for
  session-A accepted on session-B" and B recorded as danger-full-access.
- TestPresetWriteFailureInReadOnlyDirFailsClosed: full access recorded, the
  config directory made read-only, read-only chosen -> restart does not
  restore full access. Without the in-place truncation it fails with
  narrowed to read-only, write failed, restart restored "danger-full-access".
- TestSnapshotBeforeBlankSessionReuseIsRefused: snapshot the blank tab, New
  Session reuses it, set full access with the old snapshot -> refused and
  nothing recorded. Without the revision advance the set is accepted and
  recorded as danger-full-access.
- The desktop preset tests pin a host that offers every preset
  (control.SetPresetSandboxForTest), so they assert the same thing on a
  runner without an OS sandbox, where read-only and workspace-write are not
  offered. Pinned to "no sandbox", three of them fail with
  permission preset "read-only" is unavailable: shell sandbox requested but
  unavailable on this host.
- TestUnfencedModeSettersDoNotRecord: SetToolApprovalModeForTab and
  SetModeForTab leave no record.
- frontend permission-preset-session-fence.test.tsx: a navigation between the
  snapshot and the set neither applies nor records the stale choice, the
  refusal is not retried, and the tab is re-read.
- frontend composer-profile-session-navigation.test.tsx runs the real
  projection, lifecycle and controller-profile hooks through A -> B -> A.
2026-09-25 21:07:39 -07:00
..