From 92fb883cf2595549afbcb29745e48ee0fa3629f6 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:54:29 -0400 Subject: [PATCH] fix(runtime): refuse headless tab split/move into an unknown group (#27381) The headless split and move-to-group handlers answered {moved:true} when the target group did not exist, hiding desktop/server group-id drift (#26522). They now refuse with target_group_not_found, as reorder already does. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- ...e-move-headless-mobile-session-tab.test.ts | 99 +++++++++++++++++++ ...untime-move-headless-mobile-session-tab.ts | 24 +++-- 2 files changed, 117 insertions(+), 6 deletions(-) create mode 100644 src/main/runtime/orca-runtime-move-headless-mobile-session-tab.test.ts diff --git a/src/main/runtime/orca-runtime-move-headless-mobile-session-tab.test.ts b/src/main/runtime/orca-runtime-move-headless-mobile-session-tab.test.ts new file mode 100644 index 00000000000..92c8c7e0813 --- /dev/null +++ b/src/main/runtime/orca-runtime-move-headless-mobile-session-tab.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import type { + RuntimeMobileSessionTabMove, + RuntimeMobileSessionTabsSnapshot +} from '../../shared/runtime-types' + +function terminalTab(parentTabId: string, isActive: boolean) { + return { + type: 'terminal' as const, + id: `${parentTabId}::leaf-1`, + parentTabId, + leafId: 'leaf-1', + title: 'Terminal', + isActive + } +} + +// No notifier, so moves take the headless (serve) path. +function headlessRuntime(): { + runtime: OrcaRuntimeService + snapshots: Map +} { + const runtime = new OrcaRuntimeService() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the published snapshot map is protected; seeding it stands in for a prior publication. + const internals = runtime as unknown as { + mobileSessionTabsByWorktree: Map + } + const snapshots = internals.mobileSessionTabsByWorktree + snapshots.set('wt-1', { + worktree: 'wt-1', + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: 'group-a', + activeTabId: 'tab-a::leaf-1', + activeTabType: 'terminal', + tabGroups: [ + { id: 'group-a', activeTabId: 'tab-a', tabOrder: ['tab-a', 'tab-b'] }, + { id: 'group-c', activeTabId: 'tab-c', tabOrder: ['tab-c'] } + ], + tabGroupLayout: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', groupId: 'group-a' }, + second: { type: 'leaf', groupId: 'group-c' } + }, + tabs: [terminalTab('tab-a', true), terminalTab('tab-b', false), terminalTab('tab-c', false)] + }) + return { runtime, snapshots } +} + +async function expectNoOp(move: RuntimeMobileSessionTabMove): Promise { + const { runtime, snapshots } = headlessRuntime() + const before = snapshots.get('wt-1') + await expect(runtime.moveMobileSessionTab('id:wt-1', move)).resolves.toEqual({ moved: true }) + expect(snapshots.get('wt-1')).toBe(before) +} + +describe('headless session tab moves', () => { + it('refuses a split onto a group the host does not have', async () => { + const { runtime, snapshots } = headlessRuntime() + const before = snapshots.get('wt-1') + await expect( + runtime.moveMobileSessionTab('id:wt-1', { + kind: 'split', + tabId: 'tab-b', + targetGroupId: 'group-missing', + splitDirection: 'right' + }) + ).rejects.toThrow('target_group_not_found') + expect(snapshots.get('wt-1')).toBe(before) + }) + + it('refuses a move into a group the host does not have', async () => { + const { runtime, snapshots } = headlessRuntime() + const before = snapshots.get('wt-1') + await expect( + runtime.moveMobileSessionTab('id:wt-1', { + kind: 'move-to-group', + tabId: 'tab-b', + targetGroupId: 'group-missing' + }) + ).rejects.toThrow('target_group_not_found') + expect(snapshots.get('wt-1')).toBe(before) + }) + + it('keeps a same-group drop a successful no-op', async () => { + await expectNoOp({ kind: 'move-to-group', tabId: 'tab-b', targetGroupId: 'group-a' }) + }) + + it('keeps splitting the last tab off its own group a successful no-op', async () => { + await expectNoOp({ + kind: 'split', + tabId: 'tab-c', + targetGroupId: 'group-c', + splitDirection: 'right' + }) + }) +}) diff --git a/src/main/runtime/orca-runtime-move-headless-mobile-session-tab.ts b/src/main/runtime/orca-runtime-move-headless-mobile-session-tab.ts index 8e010c91650..baa75a1b6f5 100644 --- a/src/main/runtime/orca-runtime-move-headless-mobile-session-tab.ts +++ b/src/main/runtime/orca-runtime-move-headless-mobile-session-tab.ts @@ -33,11 +33,7 @@ export class OrcaRuntimeWithMoveHeadlessMobileSessionTab extends OrcaRuntimeWith if (!hostTabId) { throw new Error('tab_not_found') } - const publicSnapshot = this.toMobileSessionTabsResult(snapshot) - const targetGroup = publicSnapshot.tabGroups?.find((group) => group.id === move.targetGroupId) - if (!targetGroup) { - throw new Error('target_group_not_found') - } + const targetGroup = this.requireHeadlessTargetGroup(snapshot, move.targetGroupId) const tabOrder = this.normalizeMobileSessionTabOrder(snapshot, targetGroup, move.tabOrder) const orderIndexByParentTabId = new Map(tabOrder.map((tabId, index) => [tabId, index])) const nextTabs = [...snapshot.tabs].sort((a, b) => { @@ -87,6 +83,7 @@ export class OrcaRuntimeWithMoveHeadlessMobileSessionTab extends OrcaRuntimeWith if (!hostTabId) { throw new Error('tab_not_found') } + this.requireHeadlessTargetGroup(snapshot, move.targetGroupId) const split = buildHeadlessTabGroupSplit({ groups: snapshot.tabGroups ?? [], layout: snapshot.tabGroupLayout, @@ -125,6 +122,7 @@ export class OrcaRuntimeWithMoveHeadlessMobileSessionTab extends OrcaRuntimeWith if (!hostTabId) { throw new Error('tab_not_found') } + this.requireHeadlessTargetGroup(snapshot, move.targetGroupId) const moved = buildHeadlessTabGroupMove({ groups: snapshot.tabGroups ?? [], layout: snapshot.tabGroupLayout, @@ -133,7 +131,7 @@ export class OrcaRuntimeWithMoveHeadlessMobileSessionTab extends OrcaRuntimeWith index: move.index }) if (!moved) { - // Same-group / missing-target drop is a renderer no-op; mirror that. + // Same-group drop is a renderer no-op; mirror that. return { moved: true } } const layout = moved.layout ?? { type: 'leaf' as const, groupId: move.targetGroupId } @@ -152,6 +150,20 @@ export class OrcaRuntimeWithMoveHeadlessMobileSessionTab extends OrcaRuntimeWith return { moved: true } } + // Why: the builders return null for a missing target AND a legit no-op; only the no-op may succeed. + protected requireHeadlessTargetGroup( + snapshot: RuntimeMobileSessionTabsSnapshot, + targetGroupId: string + ): RuntimeMobileSessionTabGroup { + const targetGroup = this.toMobileSessionTabsResult(snapshot).tabGroups?.find( + (group) => group.id === targetGroupId + ) + if (!targetGroup) { + throw new Error('target_group_not_found') + } + return targetGroup + } + // Persist the headless tab-GROUP layout so snapshot rebuilds keep the split; the tab bar and rows // follow the groups. Deleted by the switch (PR 6), when moves are layout commands. protected persistHeadlessTabGroups(