feat(base,app,learn,todo,train,meditate): agent accounts with tokens

ADR 0044's step 6a (D42, D43, D45): accounts for programs, such as AI
assistants, that an identified person makes from the Profile.

halkyon_base keeps them on the instance's register, which its members
ask: an agent is an account (agent_account: its owner, its name, its
public keys, set by itself once) with one token (agent_token: 32
random bytes, kept as their SHA-256 alone, shown once). agents.create,
mine, newToken, revoke, delete and setKeys serve its owner;
agentSignIn.signIn exchanges the token for an ordinary server-side
session of a day, with the scopes identified and agent, at most 60
sign-ins an hour from a network. Revoking a token or making a new one
ends the agent's sessions; a token unused for 90 days lapses in every
service's pruning. Up to 5 agents a person, where the operator didn't
switch them off (AGENTS=off; said on the privacy page and in
instance.basics). An agent makes no agents, creates or owns no shared
space, is invited and raised as a Viewer alone, is never handed a
space on, is named no operator and granted no assistants; each refusal
has its test. Deleting a person deletes their agents first; an agent's
public profile names whose agent it is.

In the apps, every Profile has an Agents card (halkyon_app's
AgentsSection): made with a name and a handle, its token shown once to
copy, a new token, the token taken back, deleted. Learn's Share sheet
marks an agent among the members and offers it no role above Viewer.

The migrations, the module's and each host's (Learn, Meditate, Todo,
Train), are additive alone: CREATE TABLE "agent_account" with a unique
index on "agentId" (one row an agent account) and one on "ownerId" (a
person's agents, and deleting them with the person), and CREATE TABLE
"agent_token" with unique indexes on "agentId" (one token an agent)
and "tokenHash" (the sign-in finds the token by its hash). No existing
table is touched, so the server before the deploy keeps working on the
migrated database.

Whats-New: Make agents in your Profile: accounts for programs such as AI assistants, which sign in with a token and see only what you share with them.
Whats-New-De: Leg in deinem Profil Agenten an: Konten für Programme wie KI-Assistenten, die sich mit einem Token anmelden und nur sehen, was du mit ihnen teilst.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-11 19:55:27 +01:00
commit 7aee78bd87
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -0,0 +1,618 @@
import 'dart:typed_data';
import 'package:clock/clock.dart';
import 'package:halkyon_base_server/base.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
import 'package:test/test.dart';
import '../support/fixtures.dart';
import '../test_tools/serverpod_test_tools.dart';
/// The sync engine as a host hands it over, in memory: each space's
/// members, and whether each may write.
final class _Engine implements SpaceGrants {
final spaces = <UuidValue, Map<UuidValue, bool>>{};
@override
Future<UuidValue> create(
Session session, {
required UuidValue owner,
required Transaction transaction,
}) async {
final space = const Uuid().v7obj();
spaces[space] = {owner: true};
return space;
}
@override
Future<void> grant(
Session session,
UuidValue space,
UuidValue person, {
required bool writes,
required Transaction transaction,
}) async => spaces[space]![person] = writes;
@override
Future<void> revoke(
Session session,
UuidValue space,
UuidValue person, {
required Transaction transaction,
}) async => spaces[space]?.remove(person);
@override
Future<void> delete(
Session session,
UuidValue space, {
required Transaction transaction,
}) async => spaces.remove(space);
}
/// Agents (Halkyon's ADR 0044, D6, D12, D43, D45): accounts their owner
/// makes from the Profile, signing in with a token for a day at a time,
/// each a Viewer alone, which do nothing only people do; every rule refused
/// too.
void main() {
withServerpod('Agents', (sessionBuilder, endpoints) {
late Session session;
late Serverpod pod;
late _Engine engine;
late UuidValue alice;
late UuidValue bob;
/// A person with an account here and a public profile of [handle].
Future<UuidValue> person(String handle) async {
final id = (await AuthServices.instance.authUsers.create(session)).id;
await saveProfile(session, id, handle: handle, displayName: handle);
return id;
}
setUp(() async {
engine = _Engine();
useBase(sessionBuilder, spaces: engine);
session = sessionBuilder.build();
pod = session.serverpod;
alice = await person('alice');
bob = await person('bob');
});
tearDown(() => useAgents(pod, null));
TestSessionBuilder signedIn(UuidValue person) => sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(person.uuid, {
identified,
}),
);
TestSessionBuilder asAgent(UuidValue agent) => sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(agent.uuid, {
identified,
agentScope,
}),
);
final agents = endpoints.agents;
final signIn = endpoints.agentSignIn;
Matcher refused(AgentRefusal reason) => throwsA(
isA<AgentRefusedException>().having((e) => e.reason, 'reason', reason),
);
Matcher refusedInSpace(SharedSpaceRefusal reason) => throwsA(
isA<SharedSpaceRefusedException>().having(
(e) => e.reason,
'reason',
reason,
),
);
Matcher refusedToOperator(OperatorRefusal reason) => throwsA(
isA<OperatorRefusedException>().having(
(e) => e.reason,
'reason',
reason,
),
);
group('making and listing', () {
test('a person makes an agent with a name and a handle: an account '
'of its own, marked as an agent, its token shown once', () async {
final made = await agents.create(
signedIn(alice),
name: 'Claude',
handle: 'claude',
);
expect(made.token, startsWith(agentTokenPrefix));
expect(made.agent.name, 'Claude');
expect(made.agent.handle, 'claude');
expect(made.agent.hasToken, isTrue);
expect(made.agent.hasKeys, isFalse);
expect(made.agent.lastUsedAt, isNull);
final account = await AuthUser.db.findById(session, made.agent.id);
expect(account!.scopeNames, {identified.name, agentScope.name});
expect(
(await agents.mine(signedIn(alice))).map((agent) => agent.id),
[made.agent.id],
);
// Only the token's hash is kept.
final row = await AgentToken.db.findFirstRow(
session,
where: (t) => t.agentId.equals(made.agent.id),
);
expect(row!.tokenHash, isNot(contains(made.token)));
expect(row.tokenHash, hasLength(64));
});
test(
"someone else's agents are listed to nobody but their owner",
() async {
await agents.create(
signedIn(alice),
name: 'Claude',
handle: 'claude',
);
expect(await agents.mine(signedIn(bob)), isEmpty);
},
);
test("its public profile names its owner's handle", () async {
final made = await agents.create(
signedIn(alice),
name: 'Claude',
handle: 'claude',
);
final profile = await AccountRegister.of(
pod,
).profileOf(session, made.agent.id);
expect(profile!.agentOf, 'alice');
expect(
(await AccountRegister.of(pod).profileOf(session, alice))!.agentOf,
isNull,
);
});
test('a handle someone has is refused, and nothing is made', () async {
await expectLater(
agents.create(signedIn(alice), name: 'Bob', handle: 'BOB'),
throwsA(
isA<ProfileRefusedException>().having(
(e) => e.reason,
'reason',
ProfileRefusal.handleTaken,
),
),
);
expect(await agents.mine(signedIn(alice)), isEmpty);
});
test('a person has five agents at most', () async {
for (var i = 0; i < maxAgentsPerPerson; i++) {
await agents.create(signedIn(alice), name: 'A$i', handle: 'agent_$i');
}
await expectLater(
agents.create(signedIn(alice), name: 'More', handle: 'more'),
refused(AgentRefusal.tooMany),
);
// Another person's count is their own.
await agents.create(signedIn(bob), name: 'B', handle: 'bobs_agent');
});
test('where the operator switched agents off, none is made, and the '
"instance's basics say so", () async {
expect(
(await endpoints.instance.basics(sessionBuilder)).agents,
isTrue,
);
useAgents(pod, false);
await expectLater(
agents.create(signedIn(alice), name: 'Claude', handle: 'claude'),
refused(AgentRefusal.agentsOff),
);
expect(
(await endpoints.instance.basics(sessionBuilder)).agents,
isFalse,
);
});
test('an agent makes no agent', () async {
final made = await agents.create(
signedIn(alice),
name: 'Claude',
handle: 'claude',
);
await expectLater(
agents.create(asAgent(made.agent.id), name: 'Sub', handle: 'sub'),
refused(AgentRefusal.notForAgents),
);
await expectLater(
agents.mine(asAgent(made.agent.id)),
refused(AgentRefusal.notForAgents),
);
});
});
group('signing in', () {
late AgentMade made;
setUp(() async {
made = await agents.create(
signedIn(alice),
name: 'Claude',
handle: 'claude',
);
});
test('the token signs the agent in for a day, as an agent, and its '
'last use shows to its owner', () async {
final now = DateTime.utc(2026, 11, 11, 20);
final success = await withClock(
Clock.fixed(now),
() => signIn.signIn(sessionBuilder, token: made.token),
);
expect(success.authUserId, made.agent.id);
expect(success.scopeNames, {identified.name, agentScope.name});
final row = await ServerSideSession.db.findFirstRow(
session,
where: (t) => t.authUserId.equals(made.agent.id),
);
expect(row!.expiresAt, now.add(agentSessionLifetime));
expect(row.method, agentSignInMethod);
expect((await agents.mine(signedIn(alice))).single.lastUsedAt, now);
});
test('a token that is none signs nobody in', () async {
await expectLater(
signIn.signIn(sessionBuilder, token: '${agentTokenPrefix}nothing'),
refused(AgentRefusal.notValid),
);
await expectLater(
signIn.signIn(sessionBuilder, token: ''),
refused(AgentRefusal.notValid),
);
});
test('a token unused for 90 days lapses: refused, and the pruning '
'forgets it', () async {
final later = clock.now().add(
agentTokenLapsesAfter + const Duration(minutes: 1),
);
await withClock(Clock.fixed(later), () async {
await expectLater(
signIn.signIn(sessionBuilder, token: made.token),
refused(AgentRefusal.notValid),
);
expect(await forgetLapsedAgentTokens(session), 1);
});
expect((await agents.mine(signedIn(alice))).single.hasToken, isFalse);
});
test('a token used within 90 days holds on', () async {
final start = clock.now();
await withClock(
Clock.fixed(start.add(const Duration(days: 80))),
() => signIn.signIn(sessionBuilder, token: made.token),
);
await withClock(
Clock.fixed(start.add(const Duration(days: 160))),
() async {
await signIn.signIn(sessionBuilder, token: made.token);
expect(await forgetLapsedAgentTokens(session), 0);
},
);
});
test('revoked, the token signs the agent in no more, and its sessions '
'end', () async {
await signIn.signIn(sessionBuilder, token: made.token);
await agents.revoke(signedIn(alice), agent: made.agent.id);
expect(
await ServerSideSession.db.count(
session,
where: (t) => t.authUserId.equals(made.agent.id),
),
0,
);
await expectLater(
signIn.signIn(sessionBuilder, token: made.token),
refused(AgentRefusal.notValid),
);
expect((await agents.mine(signedIn(alice))).single.hasToken, isFalse);
});
test('a new token ends the one before and its sessions', () async {
await signIn.signIn(sessionBuilder, token: made.token);
final renewed = await agents.newToken(
signedIn(alice),
agent: made.agent.id,
);
expect(renewed.token, isNot(made.token));
expect(
await ServerSideSession.db.count(
session,
where: (t) => t.authUserId.equals(made.agent.id),
),
0,
);
await expectLater(
signIn.signIn(sessionBuilder, token: made.token),
refused(AgentRefusal.notValid),
);
await signIn.signIn(sessionBuilder, token: renewed.token);
});
test('nobody but its owner revokes, renews or deletes an agent, and '
"another's is answered as none", () async {
final unknown = const Uuid().v7obj();
for (final agent in [made.agent.id, unknown]) {
await expectLater(
agents.revoke(signedIn(bob), agent: agent),
refused(AgentRefusal.noSuchAgent),
);
await expectLater(
agents.newToken(signedIn(bob), agent: agent),
refused(AgentRefusal.noSuchAgent),
);
await expectLater(
agents.delete(signedIn(bob), agent: agent),
refused(AgentRefusal.noSuchAgent),
);
}
await signIn.signIn(sessionBuilder, token: made.token);
});
test('an agent signs in no other way than by its token', () async {
// It has no password, no address and no SimpleLogin: the ways a
// person signs in find nothing of it.
final ways = await AccountRegister.of(
pod,
).signInWaysOf(session, made.agent.id);
expect(ways.password, isFalse);
expect(ways.email, isFalse);
expect(ways.simpleLogin, isFalse);
});
});
group('deleting', () {
test(
'its owner deletes an agent: its account goes, and its token',
() async {
final made = await agents.create(
signedIn(alice),
name: 'Claude',
handle: 'claude',
);
await agents.delete(signedIn(alice), agent: made.agent.id);
expect(await AuthUser.db.findById(session, made.agent.id), isNull);
expect(await AgentToken.db.count(session), 0);
expect(await AgentAccount.db.count(session), 0);
expect(await agents.mine(signedIn(alice)), isEmpty);
await expectLater(
signIn.signIn(sessionBuilder, token: made.token),
refused(AgentRefusal.notValid),
);
},
);
test('deleting a person deletes their agents first', () async {
final made = await agents.create(
signedIn(alice),
name: 'Claude',
handle: 'claude',
);
await agents.create(signedIn(bob), name: 'Other', handle: 'other');
await deletePerson(session, alice);
expect(await AuthUser.db.findById(session, made.agent.id), isNull);
expect(await agents.mine(signedIn(bob)), hasLength(1));
});
});
group('keys', () {
test(
'an agent sets its public keys once, which members then find',
() async {
final made = await agents.create(
signedIn(alice),
name: 'Claude',
handle: 'claude',
);
final encryption = ByteData.sublistView(Uint8List(32)..[0] = 1);
final signing = ByteData.sublistView(Uint8List(32)..[0] = 2);
await agents.setKeys(
asAgent(made.agent.id),
encryption: encryption,
signing: signing,
);
final keys = await AccountRegister.of(
pod,
).publicKeysOf(session, {made.agent.id, alice});
expect(keys.keys, [made.agent.id]);
expect(Uint8List.sublistView(keys[made.agent.id]!.encryption)[0], 1);
expect(Uint8List.sublistView(keys[made.agent.id]!.signing)[0], 2);
expect((await agents.mine(signedIn(alice))).single.hasKeys, isTrue);
await expectLater(
agents.setKeys(
asAgent(made.agent.id),
encryption: signing,
signing: encryption,
),
refused(AgentRefusal.keysSet),
);
},
);
test("a person sets no agent's keys, nor keys as an agent", () async {
await agents.create(signedIn(alice), name: 'Claude', handle: 'claude');
final key = ByteData.sublistView(Uint8List(32));
await expectLater(
agents.setKeys(signedIn(alice), encryption: key, signing: key),
refused(AgentRefusal.notForAgents),
);
});
test('keys of the wrong length are refused', () async {
final made = await agents.create(
signedIn(alice),
name: 'Claude',
handle: 'claude',
);
final short = ByteData.sublistView(Uint8List(31));
final key = ByteData.sublistView(Uint8List(32));
await expectLater(
agents.setKeys(
asAgent(made.agent.id),
encryption: short,
signing: key,
),
throwsA(isA<ArgumentError>()),
);
});
});
group('in shared spaces', () {
late UuidValue agent;
late UuidValue space;
/// The agent's membership of [space].
Future<UuidValue> membership() async =>
(await endpoints.sharedSpace.members(
signedIn(alice),
space,
)).singleWhere((member) => member.agent).id;
setUp(() async {
agent = (await agents.create(
signedIn(alice),
name: 'Claude',
handle: 'claude',
)).agent.id;
space = (await endpoints.sharedSpace.create(
signedIn(alice),
'studyset',
)).id;
});
test('an agent is invited as a Viewer, and marked as an agent among '
'the members', () async {
await endpoints.sharedSpace.invite(
signedIn(alice),
space,
'claude',
SharedSpaceRole.viewer,
);
final members = await endpoints.sharedSpace.members(
signedIn(alice),
space,
);
expect(
{for (final member in members) member.handle: member.agent},
{'alice': false, 'claude': true},
);
expect(engine.spaces[space]![agent], isFalse);
});
test('an agent is invited as nothing but a Viewer', () async {
await expectLater(
endpoints.sharedSpace.invite(
signedIn(alice),
space,
'claude',
SharedSpaceRole.editor,
),
refusedInSpace(SharedSpaceRefusal.agentViewer),
);
expect(engine.spaces[space]!.containsKey(agent), isFalse);
});
test('an agent is raised to nothing but a Viewer', () async {
await endpoints.sharedSpace.invite(
signedIn(alice),
space,
'claude',
SharedSpaceRole.viewer,
);
await expectLater(
endpoints.sharedSpace.setRole(
signedIn(alice),
space,
await membership(),
SharedSpaceRole.editor,
),
refusedInSpace(SharedSpaceRefusal.agentViewer),
);
expect(engine.spaces[space]![agent], isFalse);
});
test('an agent creates no shared space', () async {
await expectLater(
endpoints.sharedSpace.create(asAgent(agent), 'studyset'),
refusedInSpace(SharedSpaceRefusal.agentViewer),
);
});
test('the Owner leaving with an agent the only other member, the '
'space never passes to it', () async {
await endpoints.sharedSpace.invite(
signedIn(alice),
space,
'claude',
SharedSpaceRole.viewer,
);
await expectLater(
endpoints.sharedSpace.handOn(
signedIn(alice),
space,
await membership(),
),
refusedInSpace(SharedSpaceRefusal.agentViewer),
);
await deletePerson(session, alice);
expect(engine.spaces.containsKey(space), isFalse);
});
});
group("the operator's", () {
late UuidValue agent;
setUp(() async {
agent = (await agents.create(
signedIn(alice),
name: 'Claude',
handle: 'claude',
)).agent.id;
});
TestSessionBuilder asOperator(UuidValue person) =>
sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
person.uuid,
{identified, instanceOperator},
),
);
test('an agent is never named an operator', () async {
await expectLater(
endpoints.instanceOperator.appoint(asOperator(bob), handle: 'claude'),
refusedToOperator(OperatorRefusal.isAgent),
);
});
test('an agent is granted no assistant', () async {
await expectLater(
endpoints.assistantOperator.grant(
asOperator(bob),
accounts: [agent],
assistants: const ['any'],
budget: AssistantLimits(),
),
refusedToOperator(OperatorRefusal.isAgent),
);
});
});
});
}