From 2b2523d65fc29e3d7acab79400580d6ecd55bada Mon Sep 17 00:00:00 2001 From: sladro Date: Fri, 10 Apr 2026 21:32:22 +0800 Subject: [PATCH] Guard terminal recovery against cross-session data --- .../terminal_session_coordinator.dart | 1 + .../terminal/terminal_socket_session.dart | 17 +++++- .../terminal_session_coordinator_test.dart | 56 +++++++++++++++++++ .../terminal_socket_session_test.dart | 31 ++++++++++ 4 files changed, 102 insertions(+), 3 deletions(-) diff --git a/apps/mobile_app/lib/features/terminal/terminal_session_coordinator.dart b/apps/mobile_app/lib/features/terminal/terminal_session_coordinator.dart index 2d56134..b201674 100644 --- a/apps/mobile_app/lib/features/terminal/terminal_session_coordinator.dart +++ b/apps/mobile_app/lib/features/terminal/terminal_session_coordinator.dart @@ -711,6 +711,7 @@ class TerminalSessionCoordinator extends ChangeNotifier { (item) => _JournalItem.fromJson(Map.from(item as Map)), ) + .where((item) => item.sessionId == session.sessionId) .toList(growable: false); } diff --git a/apps/mobile_app/lib/features/terminal/terminal_socket_session.dart b/apps/mobile_app/lib/features/terminal/terminal_socket_session.dart index 5827151..40aab3e 100644 --- a/apps/mobile_app/lib/features/terminal/terminal_socket_session.dart +++ b/apps/mobile_app/lib/features/terminal/terminal_socket_session.dart @@ -179,7 +179,9 @@ class TerminalSocketSession { bool _handleAttachedAck(String frame) { try { final decoded = jsonDecode(frame); - if (decoded is Map && decoded['type'] == 'attached') { + if (decoded is Map && + decoded['type'] == 'attached' && + _matchesSessionId(decoded)) { return true; } } catch (_) {} @@ -193,7 +195,9 @@ class TerminalSocketSession { ) { try { final decoded = jsonDecode(frame); - if (decoded is Map && decoded['type'] == 'restore') { + if (decoded is Map && + decoded['type'] == 'restore' && + _matchesSessionId(decoded)) { onRestore( TerminalRestorePayload.fromJson(Map.from(decoded)), ); @@ -207,7 +211,9 @@ class TerminalSocketSession { TerminalOutputPayload? _decodeOutputFrame(String frame) { try { final decoded = jsonDecode(frame); - if (decoded is Map && decoded['type'] == 'output') { + if (decoded is Map && + decoded['type'] == 'output' && + _matchesSessionId(decoded)) { return TerminalOutputPayload.fromJson( Map.from(decoded), ); @@ -225,6 +231,11 @@ class TerminalSocketSession { ); } + bool _matchesSessionId(Map decoded) { + final messageSessionId = decoded['sessionId']; + return messageSessionId is String && messageSessionId == sessionId; + } + void _handleTransportClosed(TerminalSocketTransport? transport) { if (transport == null) { _subscription = null; diff --git a/apps/mobile_app/test/features/terminal/terminal_session_coordinator_test.dart b/apps/mobile_app/test/features/terminal/terminal_session_coordinator_test.dart index 887c7d4..1046dda 100644 --- a/apps/mobile_app/test/features/terminal/terminal_session_coordinator_test.dart +++ b/apps/mobile_app/test/features/terminal/terminal_session_coordinator_test.dart @@ -784,6 +784,62 @@ void main() { expect(history!.outputSeedText, 'header\r\nmiddle\r\ntail'); }, ); + + test( + 'loadRecentHistoryWindow ignores journal items from a different session', + () async { + final controller = TerminalInteractionController(); + controller.applyFrame('skip-initial-history-load'); + final apiClient = _FakeAgentApiClient( + journalResponses: [ + { + 'sessionId': 'abc', + 'items': [ + { + 'sessionId': 'other-session', + 'sequence': 5, + 'kind': 'output', + 'payload': 'wrong-output\r\n', + 'timestampUtc': '2026-04-07T03:20:05Z', + }, + { + 'sessionId': 'abc', + 'sequence': 6, + 'kind': 'output', + 'payload': 'right-output\r\n', + 'timestampUtc': '2026-04-07T03:20:06Z', + }, + ], + 'hasMoreBefore': false, + 'hasMoreAfter': false, + 'currentSequence': 6, + }, + ], + ); + final sessionFactory = _FakeTerminalSessionFactory(); + final session = Session( + sessionId: 'abc', + name: 'codex-main', + status: 'idle', + ); + final coordinator = TerminalSessionCoordinator( + controller: controller, + apiClient: apiClient, + session: session, + sessionFactory: sessionFactory.create, + onFrame: (_) {}, + onRestore: (_) {}, + viewportProvider: () => const TerminalViewport(columns: 80, rows: 24), + ); + + await coordinator.start(); + final history = await coordinator.loadRecentHistoryWindow(); + + expect(history, isNotNull); + expect(history!.outputSeedText, 'right-output\r\n'); + expect(history.lines, ['[output] right-output']); + }, + ); } class _FakeAgentApiClient extends AgentApiClient { diff --git a/apps/mobile_app/test/features/terminal/terminal_socket_session_test.dart b/apps/mobile_app/test/features/terminal/terminal_socket_session_test.dart index aab9405..dd54ba5 100644 --- a/apps/mobile_app/test/features/terminal/terminal_socket_session_test.dart +++ b/apps/mobile_app/test/features/terminal/terminal_socket_session_test.dart @@ -220,6 +220,37 @@ void main() { expect(outputs, isEmpty); }); + + test('connect ignores restore and output frames for a different session', () async { + final transport = _FakeTerminalSocketTransport(); + final session = TerminalSocketSession( + sessionId: 'session-123', + socketClient: AgentSocketClient(Uri.parse('https://host:9443')), + transportFactory: (_) => transport, + ); + + final outputs = []; + final restores = []; + final connectFuture = session.connect( + onOutput: outputs.add, + onRestore: restores.add, + ); + await Future.delayed(Duration.zero); + + transport.emit('{"type":"attached","sessionId":"session-123"}'); + await connectFuture; + + transport.emit( + '{"type":"restore","sessionId":"session-999","sequence":4,"screenText":"wrong","pendingInput":""}', + ); + transport.emit( + '{"type":"output","sessionId":"session-999","sequence":5,"chunk":"wrong-output"}', + ); + await Future.delayed(Duration.zero); + + expect(restores, isEmpty); + expect(outputs, isEmpty); + }); } class _FakeTerminalSocketTransport implements TerminalSocketTransport {