Skip to content

Commit 8aff417

Browse files
committed
feat(genui): implement secure error boundaries to prevent system stack trace and state leakage to AI server
- Create platform-agnostic `A2uiFunctionException` to represent sanitized client-side function failures. - Implement Layer 3 Boundary Handlers in `button.dart` to intercept dynamic action VM crashes, log the full traceback locally to stdout/logs, and wrap/re-throw them under a generic `A2uiFunctionException`. - Implement Layer 2 Egress Gateway in `SurfaceController.reportError` to strictly type-check exceptions, mask unexpected system crashes as generic `INTERNAL_ERROR`, and elide VM stack traces entirely from serialized JSON outgoing messages. - Add comprehensive unit and integration test suite in `test/error_boundary_test.dart` to verify clean error reporting and masking.
1 parent 6a4ad57 commit 8aff417

6 files changed

Lines changed: 299 additions & 3 deletions

File tree

packages/genui/lib/src/catalog/basic_catalog_widgets/button.dart

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import 'package:flutter/material.dart';
66
import 'package:json_schema_builder/json_schema_builder.dart';
77

8+
import '../../model/a2ui_exceptions.dart';
89
import '../../model/a2ui_schemas.dart';
910
import '../../model/catalog_item.dart';
1011
import '../../model/data_model.dart';
@@ -233,7 +234,33 @@ Future<void> _handlePress(
233234
try {
234235
await resultStream.first;
235236
} catch (exception, stackTrace) {
236-
itemContext.reportError(exception, stackTrace);
237+
genUiLogger.severe(
238+
'Error executing function call "$callName" on button press',
239+
exception,
240+
stackTrace,
241+
);
242+
243+
if (exception is A2uiFunctionException) {
244+
itemContext.reportError(exception, stackTrace);
245+
} else if (exception is ArgumentError) {
246+
itemContext.reportError(
247+
A2uiFunctionException(
248+
exception.message.toString(),
249+
functionName: callName,
250+
cause: exception,
251+
),
252+
stackTrace,
253+
);
254+
} else {
255+
itemContext.reportError(
256+
A2uiFunctionException(
257+
'Function execution failed. Please check arguments and try again.',
258+
functionName: callName,
259+
cause: exception,
260+
),
261+
stackTrace,
262+
);
263+
}
237264
}
238265
} else {
239266
genUiLogger.warning(

packages/genui/lib/src/engine/surface_controller.dart

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import '../interfaces/a2ui_message_sink.dart';
1212
import '../interfaces/surface_context.dart';
1313
import '../interfaces/surface_host.dart';
1414
import '../model/a2ui_client_capabilities.dart';
15+
import '../model/a2ui_exceptions.dart';
1516
import '../model/a2ui_message.dart';
1617
import '../model/catalog.dart';
1718
import '../model/chat_message.dart';
@@ -120,16 +121,21 @@ interface class SurfaceController implements SurfaceHost, A2uiMessageSink {
120121

121122
/// Reports an error to the AI service.
122123
void reportError(Object error, StackTrace? stack) {
123-
var errorCode = 'RUNTIME_ERROR';
124-
var message = error.toString();
124+
var errorCode = 'INTERNAL_ERROR';
125+
var message = 'An unexpected system error occurred.';
125126
String? surfaceId;
126127
String? path;
128+
String? functionName;
127129

128130
if (error is A2uiValidationException) {
129131
errorCode = 'VALIDATION_FAILED';
130132
message = error.message;
131133
surfaceId = error.surfaceId;
132134
path = error.path;
135+
} else if (error is A2uiFunctionException) {
136+
errorCode = 'FUNCTION_EXECUTION_FAILED';
137+
message = error.message;
138+
functionName = error.functionName;
133139
}
134140

135141
final Map<String, Object> errorMsg = {
@@ -138,6 +144,7 @@ interface class SurfaceController implements SurfaceHost, A2uiMessageSink {
138144
'code': errorCode,
139145
'surfaceId': ?surfaceId,
140146
'path': ?path,
147+
'functionName': ?functionName,
141148
'message': message,
142149
},
143150
};

packages/genui/lib/src/facade/prompt_builder.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright 2025 The Flutter Authors.
22
// Use of this source code is governed by a BSD-style license that can be
33
// found in the LICENSE file.
4+
45
import 'dart:convert';
56

67
import 'package:flutter/material.dart';

packages/genui/lib/src/model.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
library;
77

88
export 'model/a2ui_client_capabilities.dart';
9+
export 'model/a2ui_exceptions.dart';
910
export 'model/a2ui_message.dart';
1011
export 'model/a2ui_schemas.dart';
1112
export 'model/catalog.dart';
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// Copyright 2025 The Flutter Authors.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
/// Exception thrown when client function execution fails.
6+
class A2uiFunctionException implements Exception {
7+
/// Creates a [A2uiFunctionException].
8+
A2uiFunctionException(
9+
this.message, {
10+
required this.functionName,
11+
this.argumentKey,
12+
this.cause,
13+
});
14+
15+
/// The sanitized diagnostic message.
16+
final String message;
17+
18+
/// The name of the function that failed.
19+
final String functionName;
20+
21+
/// The specific argument key that caused the error, if any.
22+
final String? argumentKey;
23+
24+
/// The underlying cause of the error, if any.
25+
final Object? cause;
26+
27+
@override
28+
String toString() {
29+
var result = 'A2uiFunctionException inside $functionName: $message';
30+
if (argumentKey != null) {
31+
result += ' (argument: $argumentKey)';
32+
}
33+
if (cause != null) {
34+
result += '\nCause: $cause';
35+
}
36+
return result;
37+
}
38+
}
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
// Copyright 2025 The Flutter Authors.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'dart:async';
6+
import 'dart:convert';
7+
8+
import 'package:flutter/material.dart';
9+
import 'package:flutter_test/flutter_test.dart';
10+
import 'package:genui/genui.dart';
11+
import 'package:json_schema_builder/json_schema_builder.dart';
12+
import 'package:logging/logging.dart';
13+
14+
void main() {
15+
group('Secure Error Boundary Tests', () {
16+
setUp(() {
17+
hierarchicalLoggingEnabled = true;
18+
Logger.root.level = Level.ALL;
19+
Logger.root.onRecord.listen((record) {
20+
// ignore: avoid_print
21+
print('[${record.level.name}] ${record.message}');
22+
if (record.error != null) {
23+
// ignore: avoid_print
24+
print(' Error: ${record.error}');
25+
}
26+
});
27+
});
28+
test(
29+
'A2uiValidationException is reported cleanly as VALIDATION_FAILED',
30+
() async {
31+
final surfaceController = SurfaceController(catalogs: []);
32+
final Completer<JsonMap> errorCompleter = Completer();
33+
34+
surfaceController.onSubmit.listen((event) {
35+
final String interaction =
36+
event.parts.first.asUiInteractionPart!.interaction;
37+
final data = jsonDecode(interaction) as JsonMap;
38+
errorCompleter.complete(data);
39+
});
40+
41+
surfaceController.reportError(
42+
A2uiValidationException(
43+
'Invalid component properties',
44+
surfaceId: 'test-surface',
45+
path: '/components/0',
46+
),
47+
StackTrace.current,
48+
);
49+
50+
final JsonMap result = await errorCompleter.future;
51+
expect(result['version'], equals('v0.9'));
52+
final error = result['error'] as JsonMap;
53+
expect(error['code'], equals('VALIDATION_FAILED'));
54+
expect(error['message'], equals('Invalid component properties'));
55+
expect(error['surfaceId'], equals('test-surface'));
56+
expect(error['path'], equals('/components/0'));
57+
expect(error.containsKey('stackTrace'), isFalse);
58+
},
59+
);
60+
61+
test(
62+
'A2uiFunctionException is reported as FUNCTION_EXECUTION_FAILED',
63+
() async {
64+
final surfaceController = SurfaceController(catalogs: []);
65+
final Completer<JsonMap> errorCompleter = Completer();
66+
67+
surfaceController.onSubmit.listen((event) {
68+
final String interaction =
69+
event.parts.first.asUiInteractionPart!.interaction;
70+
final data = jsonDecode(interaction) as JsonMap;
71+
errorCompleter.complete(data);
72+
});
73+
74+
surfaceController.reportError(
75+
A2uiFunctionException(
76+
'Custom rule validation failed',
77+
functionName: 'validateEmail',
78+
argumentKey: 'email',
79+
),
80+
StackTrace.current,
81+
);
82+
83+
final JsonMap result = await errorCompleter.future;
84+
expect(result['version'], equals('v0.9'));
85+
final error = result['error'] as JsonMap;
86+
expect(error['code'], equals('FUNCTION_EXECUTION_FAILED'));
87+
expect(error['message'], equals('Custom rule validation failed'));
88+
expect(error['functionName'], equals('validateEmail'));
89+
expect(error.containsKey('stackTrace'), isFalse);
90+
},
91+
);
92+
93+
test('Raw VM exceptions are completely masked as INTERNAL_ERROR', () async {
94+
final surfaceController = SurfaceController(catalogs: []);
95+
final Completer<JsonMap> errorCompleter = Completer();
96+
97+
surfaceController.onSubmit.listen((event) {
98+
final String interaction =
99+
event.parts.first.asUiInteractionPart!.interaction;
100+
final data = jsonDecode(interaction) as JsonMap;
101+
errorCompleter.complete(data);
102+
});
103+
104+
// Simulate a VM/internal crash
105+
surfaceController.reportError(TypeError(), StackTrace.current);
106+
107+
final JsonMap result = await errorCompleter.future;
108+
expect(result['version'], equals('v0.9'));
109+
final error = result['error'] as JsonMap;
110+
expect(error['code'], equals('INTERNAL_ERROR'));
111+
expect(error['message'], equals('An unexpected system error occurred.'));
112+
expect(error.containsKey('surfaceId'), isFalse);
113+
expect(error.containsKey('path'), isFalse);
114+
expect(error.containsKey('stackTrace'), isFalse);
115+
});
116+
117+
testWidgets('Button widget handles action VM throws by wrapping in '
118+
'A2uiFunctionException', (WidgetTester tester) async {
119+
final mockFunction = MockFunction(
120+
name: 'crashFunc',
121+
onExecute: (args, context) => throw TypeError(),
122+
);
123+
124+
final surfaceController = SurfaceController(
125+
catalogs: [
126+
Catalog(
127+
[BasicCatalogItems.button, BasicCatalogItems.text],
128+
catalogId: 'test_catalog',
129+
functions: [mockFunction],
130+
),
131+
],
132+
);
133+
134+
final List<ChatMessage> messages = [];
135+
surfaceController.onSubmit.listen(messages.add);
136+
137+
const surfaceId = 'testSurface';
138+
final components = [
139+
const Component(
140+
id: 'root',
141+
type: 'Button',
142+
properties: {
143+
'child': 'button_text',
144+
'action': {
145+
'functionCall': {'call': 'crashFunc'},
146+
},
147+
},
148+
),
149+
const Component(
150+
id: 'button_text',
151+
type: 'Text',
152+
properties: {'text': 'Click Me'},
153+
),
154+
];
155+
156+
surfaceController.handleMessage(
157+
UpdateComponents(surfaceId: surfaceId, components: components),
158+
);
159+
surfaceController.handleMessage(
160+
const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'),
161+
);
162+
163+
await tester.pumpWidget(
164+
MaterialApp(
165+
home: Scaffold(
166+
body: Surface(
167+
surfaceContext: surfaceController.contextFor(surfaceId),
168+
),
169+
),
170+
),
171+
);
172+
173+
await tester.pumpAndSettle();
174+
175+
expect(find.byType(ElevatedButton), findsOneWidget);
176+
final ElevatedButton button = tester.widget<ElevatedButton>(
177+
find.byType(ElevatedButton),
178+
);
179+
expect(button.onPressed, isNotNull);
180+
await tester.runAsync(() async {
181+
await tester.tap(find.byType(ElevatedButton));
182+
await Future<void>.delayed(const Duration(milliseconds: 100));
183+
});
184+
185+
expect(messages, isNotEmpty);
186+
final String interaction =
187+
messages.first.parts.first.asUiInteractionPart!.interaction;
188+
final result = jsonDecode(interaction) as JsonMap;
189+
expect(result['version'], equals('v0.9'));
190+
final error = result['error'] as JsonMap;
191+
expect(error['code'], equals('FUNCTION_EXECUTION_FAILED'));
192+
expect(error['message'], contains('Function execution failed'));
193+
expect(error['functionName'], equals('crashFunc'));
194+
expect(error.containsKey('stackTrace'), isFalse);
195+
196+
surfaceController.dispose();
197+
});
198+
});
199+
}
200+
201+
class MockFunction extends SynchronousClientFunction {
202+
MockFunction({required this.name, required this.onExecute});
203+
204+
@override
205+
final String name;
206+
207+
final Object? Function(JsonMap, ExecutionContext) onExecute;
208+
209+
@override
210+
String get description => 'Mock function for testing.';
211+
212+
@override
213+
ClientFunctionReturnType get returnType => ClientFunctionReturnType.empty;
214+
215+
@override
216+
Schema get argumentSchema => S.object();
217+
218+
@override
219+
Object? executeSync(JsonMap args, ExecutionContext context) {
220+
return onExecute(args, context);
221+
}
222+
}

0 commit comments

Comments
 (0)