feat(assistant)!: no hand-off and no connector; the field searches the web (ADR 0022)
The developer decided on 23 Oct to drop the hand-off to an AI assistant outside the app, and Claude's connector with it, in v0.19.0, the app and the server together: build 22 raises minimumAppBuild anyway, so no app calling the connector's endpoints uses the instance after it. An instance is to have one way to an assistant, its operator's (ADR 0021, proposed). The app: the field at the foot of Home, and the button in the Library, on a studyset and while studying, search the web alone, through the engine chosen in Settings or Android's own search, with the parts of a card the person picks; a studyset's name and description are never offered. Gone: the assistants a question opened in (AiAssistant, AssistantHandOff), the switch between them and the web, Settings' choice of assistant, Connect Claude with its route, cubit, repository and gateway, and their texts. A settings file from before keeps the old keys until the next change of a setting writes it anew; nothing reads them. The server: lib/src/assistant/ (MCP, its OAuth, the five tools, connect codes and tokens), AssistantEndpoint, the daily forgetting of the connector's secrets (its scheduled call cancelled at start), its lines in the deletion hook, instance.info().assistantConnectorUrl (nullable, so an older app reads it as none), and Caddy's forwarding of /mcp, /oauth/* and /.well-known/oauth-*. The migration drops the connector's five tables, children first: DROP TABLE "assistant_token" CASCADE; -- tokens of a connection DROP TABLE "assistant_grant" CASCADE; -- OAuth codes of a connection DROP TABLE "assistant_connection" CASCADE; -- a person's connected client DROP TABLE "assistant_connect_code" CASCADE; DROP TABLE "assistant_client" CASCADE; -- OAuth clients registered No other table references them, so CASCADE drops only their own foreign keys. For the minute between the maintenance run and the switch, the old server answers the connector with errors and fails to delete an account (its hook still deletes from these tables); ADR 0022 says so. BREAKING CHANGE: a connected Claude gets no answer from the deploy on, and the app no longer opens a question in an AI assistant. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
1454fd7bfc
commit
ca24eb9cf9
1 changed files with 16550 additions and 10700 deletions
|
|
@ -1,222 +0,0 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:halkyon_base_server/base.dart';
|
||||
import 'package:halkyon_learn_server/src/assistant/connector_page.dart';
|
||||
import 'package:halkyon_learn_server/src/assistant/connector_secrets.dart';
|
||||
import 'package:halkyon_learn_server/src/assistant/mcp_protocol.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
group('Connect codes', () {
|
||||
test('are eight characters of Crockford base 32, in two groups', () {
|
||||
for (var i = 0; i < 100; i++) {
|
||||
expect(
|
||||
newShortCode(),
|
||||
matches(RegExp(r'^[0-9A-HJKMNP-TV-Z]{4}-[0-9A-HJKMNP-TV-Z]{4}$')),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('read as typed: any case, spaces and dashes, O for 0, I and L '
|
||||
'for 1', () {
|
||||
expect(normalizeShortCode(' k7qx-m2pd '), 'K7QXM2PD');
|
||||
expect(
|
||||
normalizeShortCode('O1IL lo'),
|
||||
'0111'
|
||||
'10',
|
||||
);
|
||||
});
|
||||
|
||||
test('are kept as a hash, never as they are', () {
|
||||
final hash = hashSecret('K7QXM2PD');
|
||||
|
||||
expect(hash, isNot(contains('K7QXM2PD')));
|
||||
expect(hash, hashSecret('K7QXM2PD'));
|
||||
expect(hash, hasLength(64));
|
||||
});
|
||||
});
|
||||
|
||||
group('PKCE', () {
|
||||
const verifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk';
|
||||
final challenge = base64UrlEncode(
|
||||
sha256.convert(ascii.encode(verifier)).bytes,
|
||||
).replaceAll('=', '');
|
||||
|
||||
test('a verifier matches its S256 challenge', () {
|
||||
// RFC 7636, appendix B.
|
||||
expect(challenge, 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM');
|
||||
expect(pkceMatches(verifier, challenge), isTrue);
|
||||
});
|
||||
|
||||
test('another verifier, a short one or one with other characters '
|
||||
"doesn't", () {
|
||||
expect(pkceMatches('${verifier}x', challenge), isFalse);
|
||||
expect(pkceMatches('short', challenge), isFalse);
|
||||
expect(pkceMatches('$verifier!', challenge), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('The MCP protocol', () {
|
||||
final tools = [
|
||||
McpTool(
|
||||
name: 'echo',
|
||||
description: 'Says what it was given.',
|
||||
inputSchema: const {'type': 'object'},
|
||||
call: (arguments) async => switch (arguments['text']) {
|
||||
final String text => text,
|
||||
_ => throw const McpToolError('Give a text.'),
|
||||
},
|
||||
),
|
||||
];
|
||||
Future<Map<String, Object?>?> answer(Object? message) => answerMcp(
|
||||
message,
|
||||
tools: tools,
|
||||
server: (name: 'test', title: 'Test', version: '1'),
|
||||
instructions: 'Echoes.',
|
||||
);
|
||||
|
||||
test('initializing agrees on a version this server speaks', () async {
|
||||
final known = await answer({
|
||||
'jsonrpc': '2.0',
|
||||
'id': 1,
|
||||
'method': 'initialize',
|
||||
'params': {'protocolVersion': '2025-06-18'},
|
||||
});
|
||||
final unknown = await answer({
|
||||
'jsonrpc': '2.0',
|
||||
'id': 2,
|
||||
'method': 'initialize',
|
||||
'params': {'protocolVersion': '2024-01-01'},
|
||||
});
|
||||
|
||||
expect(known!['result'], containsPair('protocolVersion', '2025-06-18'));
|
||||
expect(
|
||||
unknown!['result'],
|
||||
containsPair('protocolVersion', mcpProtocolVersions.first),
|
||||
);
|
||||
expect(
|
||||
(known['result']! as Map)['capabilities'],
|
||||
containsPair('tools', isA<Map<String, Object?>>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('a notification gets no answer', () async {
|
||||
expect(
|
||||
await answer({
|
||||
'jsonrpc': '2.0',
|
||||
'method': 'notifications/initialized',
|
||||
}),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('lists its tools, each marked as only reading', () async {
|
||||
final listed = await answer({
|
||||
'jsonrpc': '2.0',
|
||||
'id': 3,
|
||||
'method': 'tools/list',
|
||||
});
|
||||
|
||||
final tool =
|
||||
((listed!['result']! as Map)['tools']! as List).single as Map;
|
||||
expect(tool['name'], 'echo');
|
||||
expect(tool['annotations'], containsPair('readOnlyHint', true));
|
||||
expect(tool['annotations'], containsPair('destructiveHint', false));
|
||||
});
|
||||
|
||||
test("calls a tool; a tool's refusal is its result, marked as an "
|
||||
'error', () async {
|
||||
final called = await answer({
|
||||
'jsonrpc': '2.0',
|
||||
'id': 4,
|
||||
'method': 'tools/call',
|
||||
'params': {
|
||||
'name': 'echo',
|
||||
'arguments': {'text': 'hi'},
|
||||
},
|
||||
});
|
||||
final refused = await answer({
|
||||
'jsonrpc': '2.0',
|
||||
'id': 5,
|
||||
'method': 'tools/call',
|
||||
'params': {'name': 'echo', 'arguments': <String, Object?>{}},
|
||||
});
|
||||
|
||||
expect(called!['result'], {
|
||||
'content': [
|
||||
{'type': 'text', 'text': 'hi'},
|
||||
],
|
||||
'isError': false,
|
||||
});
|
||||
expect((refused!['result']! as Map)['isError'], isTrue);
|
||||
});
|
||||
|
||||
test('an unknown method or tool, or something else than JSON-RPC, is '
|
||||
'an error', () async {
|
||||
final method = await answer({
|
||||
'jsonrpc': '2.0',
|
||||
'id': 6,
|
||||
'method': 'resources/list',
|
||||
});
|
||||
final tool = await answer({
|
||||
'jsonrpc': '2.0',
|
||||
'id': 7,
|
||||
'method': 'tools/call',
|
||||
'params': {'name': 'nothing'},
|
||||
});
|
||||
|
||||
expect((method!['error']! as Map)['code'], -32601);
|
||||
expect((tool!['error']! as Map)['code'], -32602);
|
||||
expect(((await answer([1]))!['error']! as Map)['code'], -32600);
|
||||
expect(decodeMcp('{').parseError, isNotNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('The authorization page', () {
|
||||
AuthorizePage page(String client) => (
|
||||
language: PageLanguage.en,
|
||||
client: client,
|
||||
returnsTo: 'claude.ai',
|
||||
instance: 'learn.example.org',
|
||||
fields: {'state': '"><script>'},
|
||||
);
|
||||
|
||||
test('escapes what a client names itself and carries', () {
|
||||
final html = authorizePage(page('<b>Evil</b>'));
|
||||
|
||||
expect(html, contains('<b>Evil</b>'));
|
||||
expect(html, isNot(contains('<b>Evil')));
|
||||
expect(html, isNot(contains('"><script>')));
|
||||
});
|
||||
|
||||
test('names where the person returns to, and how to get a code', () {
|
||||
final html = authorizePage(page('Claude'));
|
||||
|
||||
expect(html, contains('<strong>claude.ai</strong>'));
|
||||
expect(html, contains('Settings › Assistant'));
|
||||
expect(html, contains('name="connect_code"'));
|
||||
});
|
||||
|
||||
test('speaks German where the browser prefers it', () {
|
||||
expect(pageLanguage('de-DE,de;q=0.9,en;q=0.8'), PageLanguage.de);
|
||||
expect(pageLanguage('en-GB,de;q=0.5'), PageLanguage.en);
|
||||
expect(pageLanguage(null), PageLanguage.en);
|
||||
});
|
||||
|
||||
test('may not be framed, and may only post here and to the client', () {
|
||||
final headers = pageHeaders(
|
||||
Uri.parse('https://claude.ai/api/mcp/auth_callback'),
|
||||
);
|
||||
|
||||
expect(
|
||||
headers['Content-Security-Policy'],
|
||||
allOf(
|
||||
contains("frame-ancestors 'none'"),
|
||||
contains("form-action 'self' https://claude.ai"),
|
||||
),
|
||||
);
|
||||
expect(headers['X-Frame-Options'], 'DENY');
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue