From c313f8ae1a45bfc94ef4d07c6f50e3271ba4526d Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Sun, 1 Nov 2026 17:14:11 -0800 Subject: [PATCH] fix(mobile): harden agent card loading and link sign-in - Don't follow redirects for agent.json, check content-length up front, and stop buffering at the cap. - Any unexpected failure while loading a card re-enables Create and shows a message instead of leaving the page stuck. - A link that needs sign-in is dropped only after sign-in was seen running, not on an effect rerun before reconnectAccount reaches that stage. - Table tests for fetch: request URL and redirects, 403/404, 302, 500, network error, declared and streamed oversize bodies, non-JSON, array. Co-authored-by: studio Signed-off-by: Tom Brow --- .../features/agents/agent_creation_draft.dart | 17 ++-- mobile/lib/features/agents/agents_page.dart | 34 ++++++-- .../test/features/agents/agents_harness.dart | 4 +- .../deeplink/agent_creation_link_test.dart | 86 +++++++++++++++++++ 4 files changed, 127 insertions(+), 14 deletions(-) diff --git a/mobile/lib/features/agents/agent_creation_draft.dart b/mobile/lib/features/agents/agent_creation_draft.dart index aebc1989ff..016e804172 100644 --- a/mobile/lib/features/agents/agent_creation_draft.dart +++ b/mobile/lib/features/agents/agent_creation_draft.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:typed_data'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:http/http.dart' as http; @@ -66,7 +67,10 @@ class AgentCreationDraft { try { response = await client .send( - http.Request('GET', url)..headers['accept'] = 'application/json', + http.Request('GET', url) + ..headers['accept'] = 'application/json' + // A redirect could lead off the CDN; treat it as unavailable. + ..followRedirects = false, ) .timeout(_fetchTimeout); if (response.statusCode == 403 || response.statusCode == 404) { @@ -77,6 +81,9 @@ class AgentCreationDraft { 'Couldn’t load this agent card (HTTP ${response.statusCode}).', ); } + if ((response.contentLength ?? 0) > maxAgentCardBytes) { + throw const AgentCardException('This agent card is too large.'); + } body = await _readBounded(response.stream).timeout(_fetchTimeout); } on AgentCardException { rethrow; @@ -159,13 +166,13 @@ class AgentCreationDraft { } static Future> _readBounded(Stream> stream) async { - final bytes = []; + final bytes = BytesBuilder(copy: false); await for (final chunk in stream) { - bytes.addAll(chunk); - if (bytes.length > maxAgentCardBytes) { + if (bytes.length + chunk.length > maxAgentCardBytes) { throw const AgentCardException('This agent card is too large.'); } + bytes.add(chunk); } - return bytes; + return bytes.takeBytes(); } } diff --git a/mobile/lib/features/agents/agents_page.dart b/mobile/lib/features/agents/agents_page.dart index eeade78690..4fa524442a 100644 --- a/mobile/lib/features/agents/agents_page.dart +++ b/mobile/lib/features/agents/agents_page.dart @@ -305,6 +305,11 @@ class AgentsPage extends HookConsumerWidget { () { final link = draft; if (link == null || draftStep.value == _DraftStep.done) return null; + // Sign-in counts as attempted only once it is seen running, so a + // rerun before reconnectAccount reaches its sign-in stage still waits. + if (signingIn && draftStep.value == _DraftStep.signInRequested) { + draftStep.value = _DraftStep.signingIn; + } if (community == null || controller.busy || signingIn) return null; if (!supportsRemoteAgents(community)) { drop('Remote agents aren’t available in this community.'); @@ -312,9 +317,16 @@ class AgentsPage extends HookConsumerWidget { } if (controller.account.account == null) { if (draftStep.value == _DraftStep.wait) { - draftStep.value = _DraftStep.signingIn; - unawaited(flow.reconnectAccount()); - } else { + draftStep.value = _DraftStep.signInRequested; + unawaited( + flow.reconnectAccount().catchError((Object error) { + debugPrint('agents: link sign-in failed: $error'); + if (context.mounted) { + drop('Couldn’t start Builderlab sign-in. Reopen the link.'); + } + }), + ); + } else if (draftStep.value == _DraftStep.signingIn) { drop( 'Sign in to Builderlab to create this agent. Reopen the link.', ); @@ -342,10 +354,17 @@ class AgentsPage extends HookConsumerWidget { final AgentCreationDraft resolved; try { resolved = await AgentCreationDraft.fetch(link, client); - } on AgentCardException catch (error) { + } catch (error) { + if (error is! AgentCardException) { + debugPrint('agents: agent card failed: $error'); + } if (context.mounted) { creating.value = false; - drop(error.message); + drop( + error is AgentCardException + ? error.message + : 'Couldn’t open this agent card.', + ); } return; } finally { @@ -505,5 +524,6 @@ class AgentsPage extends HookConsumerWidget { } } -/// Progress of opening a link's draft: waiting on gates, in sign-in, or done. -enum _DraftStep { wait, signingIn, done } +/// Progress of opening a link's draft: waiting on gates, sign-in requested, +/// sign-in seen running, or done. +enum _DraftStep { wait, signInRequested, signingIn, done } diff --git a/mobile/test/features/agents/agents_harness.dart b/mobile/test/features/agents/agents_harness.dart index 592d3604a6..0f9b21bf43 100644 --- a/mobile/test/features/agents/agents_harness.dart +++ b/mobile/test/features/agents/agents_harness.dart @@ -19,7 +19,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:hooks_riverpod/misc.dart' show Override; +import 'package:hooks_riverpod/misc.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../../helpers/remote_agents_fixture.dart'; @@ -209,7 +209,6 @@ class AgentsHarness { await tester.pumpWidget( ProviderScope( overrides: [ - ...overrides, ownedAgentDirectoryProvider.overrideWith( (ref) => loadOwnedAgentDirectory(fakeOwner.public, relay.fetchHistory), @@ -229,6 +228,7 @@ class AgentsHarness { AgentDirectoryEntry(pubkey: pubkey), ], ), + ...overrides, ], child: MaterialApp( theme: AppTheme.light(), diff --git a/mobile/test/shared/deeplink/agent_creation_link_test.dart b/mobile/test/shared/deeplink/agent_creation_link_test.dart index fabee3bb75..b8280ebedd 100644 --- a/mobile/test/shared/deeplink/agent_creation_link_test.dart +++ b/mobile/test/shared/deeplink/agent_creation_link_test.dart @@ -1,6 +1,11 @@ +import 'dart:convert'; + import 'package:buzz/features/agents/agent_creation_draft.dart'; import 'package:buzz/shared/deeplink/deep_link.dart'; +import 'package:buzz/shared/hosted/hosted_environment.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; void main() { group('buzz://agent/new links', () { @@ -169,4 +174,85 @@ void main() { }); }); }); + + group('fetching agent.json', () { + const link = AgentCreationDeepLink('av_1'); + final big = 'x' * (maxAgentCardBytes + 1); + + Future outcome(MockClientStreamHandler handler) async { + try { + return await AgentCreationDraft.fetch( + link, + MockClient.streaming(handler), + ); + } on AgentCardException catch (error) { + return error.message; + } + } + + http.StreamedResponse respond( + int status, [ + List> chunks = const [], + int? length, + ]) => http.StreamedResponse( + Stream.fromIterable(chunks), + status, + contentLength: length, + ); + + test('requests the build CDN without following redirects', () async { + late http.BaseRequest seen; + final result = await outcome((request, _) async { + seen = request; + return respond(200, [utf8.encode('{"name":"Scout"}')]); + }); + expect(result, isA()); + expect( + seen.url, + HostedEnvironment.avatarsCdnOrigin.replace(path: '/av_1/agent.json'), + ); + expect(seen.followRedirects, isFalse); + }); + + final failures = { + '403 from S3': ( + (_, _) async => respond(403), + 'This agent card isn’t available.', + ), + '404': ((_, _) async => respond(404), 'This agent card isn’t available.'), + 'redirect': ( + (_, _) async => respond(302), + 'Couldn’t load this agent card (HTTP 302).', + ), + '500': ( + (_, _) async => respond(500), + 'Couldn’t load this agent card (HTTP 500).', + ), + 'network error': ( + (_, _) async => throw http.ClientException('offline'), + 'Couldn’t load this agent card. Check your connection and reopen the ' + 'link.', + ), + 'declared length over the cap': ( + (_, _) async => respond(200, const [], maxAgentCardBytes + 1), + 'This agent card is too large.', + ), + 'streamed body over the cap': ( + (_, _) async => + respond(200, [utf8.encode('{"name":"'), utf8.encode(big)]), + 'This agent card is too large.', + ), + 'not JSON': ( + (_, _) async => respond(200, [utf8.encode('')]), + 'This agent card is invalid.', + ), + 'JSON array': ( + (_, _) async => respond(200, [utf8.encode('[]')]), + 'This agent card is invalid.', + ), + }; + failures.forEach((label, c) { + test(label, () async => expect(await outcome(c.$1), c.$2)); + }); + }); }