Guard terminal recovery against cross-session data

This commit is contained in:
sladro 2026-04-10 21:32:22 +08:00
parent 7b401eb3f6
commit 2b2523d65f
4 changed files with 102 additions and 3 deletions

View File

@ -711,6 +711,7 @@ class TerminalSessionCoordinator extends ChangeNotifier {
(item) =>
_JournalItem.fromJson(Map<String, dynamic>.from(item as Map)),
)
.where((item) => item.sessionId == session.sessionId)
.toList(growable: false);
}

View File

@ -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<String, dynamic>.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<String, dynamic>.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;

View File

@ -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: [
<String, dynamic>{
'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 {

View File

@ -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 = <TerminalOutputPayload>[];
final restores = <TerminalRestorePayload>[];
final connectFuture = session.connect(
onOutput: outputs.add,
onRestore: restores.add,
);
await Future<void>.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<void>.delayed(Duration.zero);
expect(restores, isEmpty);
expect(outputs, isEmpty);
});
}
class _FakeTerminalSocketTransport implements TerminalSocketTransport {