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 <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Signed-off-by: Tom Brow <tomb@squareup.com>
This commit is contained in:
Tom Brow
2026-11-01 17:14:11 -08:00
parent ab516337a5
commit c313f8ae1a
4 changed files with 127 additions and 14 deletions

View File

@@ -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<List<int>> _readBounded(Stream<List<int>> stream) async {
final bytes = <int>[];
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();
}
}

View File

@@ -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 }

View File

@@ -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(),

View File

@@ -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<Object> 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<List<int>> 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<AgentCreationDraft>());
expect(
seen.url,
HostedEnvironment.avatarsCdnOrigin.replace(path: '/av_1/agent.json'),
);
expect(seen.followRedirects, isFalse);
});
final failures = <String, (MockClientStreamHandler, String)>{
'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('<html>')]),
'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));
});
});
}