halkyon/apps/learn/halkyon_learn_agent/test/agent_home_test.dart
Tizian 「ティツィアーン」 54e212a51c
Some checks failed
CI / check (push) Failing after 1m56s
feat(learn): Learn's agent, an MCP server suggesting in shared studysets
Halkyon's ADR 0044, step 6c (D7, D49 to D52). apps/learn/halkyon_learn_agent
is one executable, `halkyon-agent --instance URL sign-in | sync | mcp`:

- sign-in keeps the token read from stdin, signs in with it, makes the
  agent's key pair on its first sign-in, keeps it before setting the
  public halves on the register (agents.setKeys), and syncs;
- it opens its data as the app's _openPersonData does, on
  halkyon_learn_core and halkyon_content: its own SQLite database, the
  sync's space router, its keys and those of the spaces shared with it,
  and the repositories of studysets, cards, notes, paths, files and
  feedback;
- mcp serves ten tools over stdio (dart_mcp): list, read and search the
  studysets shared with it, suggest a change or a new card or note,
  report, list a studyset's suggestions and reply. Each syncs first and
  a suggestion again at once; a refusal is the tool's error;
- its files live in $XDG_CONFIG_HOME/halkyon-agent/<host>, 0700 and
  0600, refused when others may read them (D50);
- tool/install.sh builds it with `dart build cli` (libsodium and SQLite
  bundled) and copies it to its user on the host, the build before kept
  (D52).

The synced gateways of learning paths and files, and the fakes of the
core's gateways, move into halkyon_learn_core (its testing.dart), which
the app's tests and the agent's share.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-11 22:08:31 +01:00

107 lines
3.5 KiB
Dart

import 'dart:io';
import 'dart:typed_data';
import 'package:halkyon_content/halkyon_content.dart';
import 'package:halkyon_learn_agent/src/agent_home.dart';
import 'package:path/path.dart' as p;
import 'package:test/test.dart';
/// Where the agent keeps its token, keys and data (Halkyon's ADR 0044,
/// D50): a folder per instance only its user may enter, files only it may
/// read, and nothing read that others could have read or changed.
void main() {
late Directory root;
setUp(() async {
root = await Directory.systemTemp.createTemp('agent_home');
addTearDown(() => root.delete(recursive: true));
});
int modeOf(String path) => FileStat.statSync(path).mode & 0x1ff;
test(
'each instance has a folder of its own, its user alone entering it',
() async {
final learn = await AgentHome.open(
root,
Uri.parse('https://learn.halkyon.eu/'),
);
final local = await AgentHome.open(
root,
Uri.parse('http://localhost:8080'),
);
expect(p.basename(learn.folder.path), 'learn.halkyon.eu');
expect(p.basename(local.folder.path), 'localhost_8080');
expect(modeOf(learn.folder.path), 0x1c0); // 0700
},
);
test('the token is kept for its user alone, and read back', () async {
final home = await AgentHome.open(root, Uri.parse('https://halkyon.li'));
expect(await home.token(), isNull);
await home.keepToken('hlk_agent_secret\n');
expect(await home.token(), 'hlk_agent_secret');
expect(modeOf(p.join(home.folder.path, 'token')), 0x180); // 0600
});
test(
"a token, keys or folder others may read or change isn't read",
() async {
final home = await AgentHome.open(root, Uri.parse('https://halkyon.li'));
await home.keepToken('secret');
await Process.run('chmod', ['644', p.join(home.folder.path, 'token')]);
await expectLater(home.token(), throwsA(isA<AgentHomeRefused>()));
await Process.run('chmod', ['600', p.join(home.folder.path, 'token')]);
await Process.run('chmod', ['755', home.folder.path]);
await expectLater(
AgentHome.open(root, Uri.parse('https://halkyon.li')),
throwsA(isA<AgentHomeRefused>()),
);
},
);
test(
'the keys are kept for its user alone, and read back as they were',
() async {
final home = await AgentHome.open(root, Uri.parse('https://halkyon.li'));
expect(await home.keys(), isNull);
final keys = UnlockedAccountKeys(
encryptionPublicKey: Uint8List.fromList(List.filled(32, 1)),
encryptionSecretKey: Uint8List.fromList(List.filled(32, 2)),
signingPublicKey: Uint8List.fromList(List.filled(32, 3)),
signingSecretKey: Uint8List.fromList(List.filled(64, 4)),
);
await home.keepKeys(keys);
expect(await home.keys(), keys);
expect(modeOf(p.join(home.folder.path, 'keys.json')), 0x180);
},
);
test('notes once the register holds its public keys', () async {
final home = await AgentHome.open(root, Uri.parse('https://halkyon.li'));
expect(await home.keysSet(), isFalse);
await home.noteKeysSet();
expect(await home.keysSet(), isTrue);
});
test('an instance is reached over HTTPS, or plain HTTP on this machine '
'alone', () async {
await expectLater(
AgentHome.open(root, Uri.parse('http://halkyon.li')),
throwsA(isA<AgentHomeRefused>()),
);
await expectLater(
AgentHome.open(root, Uri.parse('ftp://halkyon.li')),
throwsA(isA<AgentHomeRefused>()),
);
});
}