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:
parent
fb6b78fa19
commit
7aee78bd87
5 changed files with 113257 additions and 50 deletions
|
|
@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -39,6 +39,7 @@ void main() {
|
|||
tearDown(() {
|
||||
useSignUpMode(pod, null);
|
||||
useAssistants(pod, null);
|
||||
useAgents(pod, null);
|
||||
});
|
||||
|
||||
String page({
|
||||
|
|
@ -56,6 +57,21 @@ void main() {
|
|||
expect(page(), contains('Nobody can make an account here now.'));
|
||||
});
|
||||
|
||||
test("says whether people may make agents (Halkyon's ADR 0044, D45), and "
|
||||
'what one may do', () {
|
||||
expect(page(), contains('People may make agents here'));
|
||||
expect(
|
||||
page(acceptLanguage: 'de'),
|
||||
contains('Hier kann man Agenten anlegen'),
|
||||
);
|
||||
useAgents(pod, false);
|
||||
expect(page(), contains('Nobody can make agents here.'));
|
||||
expect(
|
||||
page(acceptLanguage: 'de'),
|
||||
contains('Hier kann niemand Agenten anlegen.'),
|
||||
);
|
||||
});
|
||||
|
||||
test('says where the data and the backups are, or that the operator '
|
||||
"doesn't say", () {
|
||||
expect(
|
||||
|
|
|
|||
|
|
@ -67,6 +67,11 @@ final class _Register implements AccountRegister {
|
|||
/// Thrown by every call while set: the register refusing, or down.
|
||||
Exception? failing;
|
||||
|
||||
/// Its agents' tokens, the agent each signs in, and each agent's owner
|
||||
/// (Halkyon's ADR 0044, D45).
|
||||
final agentTokens = <String, UuidValue>{};
|
||||
final agentOwnerOf = <UuidValue, UuidValue>{};
|
||||
|
||||
/// The base URLs sign-ins were bound to, as members named them.
|
||||
final instanceUrls = <String>[];
|
||||
|
||||
|
|
@ -879,6 +884,90 @@ final class _Register implements AccountRegister {
|
|||
@override
|
||||
Future<void> forgetWaitingPass(Session session, String pass) =>
|
||||
_answer('forgetWaitingPass', () => passesForgotten.add(pass));
|
||||
|
||||
@override
|
||||
Future<bool> agentsOffered(Session session) =>
|
||||
_answer('agentsOffered', () => true);
|
||||
|
||||
@override
|
||||
Future<AgentMade> createAgent(
|
||||
Session session, {
|
||||
required UuidValue owner,
|
||||
required String name,
|
||||
required String handle,
|
||||
required String network,
|
||||
}) => _answer('createAgent', () {
|
||||
networks.add(network);
|
||||
final agent = const Uuid().v7obj();
|
||||
agentOwnerOf[agent] = owner;
|
||||
agentTokens['${agentTokenPrefix}made'] = agent;
|
||||
return AgentMade(
|
||||
agent: AgentInfo(
|
||||
id: agent,
|
||||
name: name,
|
||||
handle: handle,
|
||||
createdAt: DateTime.utc(2026, 11, 11),
|
||||
hasToken: true,
|
||||
hasKeys: false,
|
||||
),
|
||||
token: '${agentTokenPrefix}made',
|
||||
);
|
||||
});
|
||||
|
||||
@override
|
||||
Future<List<AgentInfo>> agentsOf(Session session, UuidValue owner) =>
|
||||
_answer('agentsOf', () => const []);
|
||||
|
||||
@override
|
||||
Future<AgentMade> newAgentToken(
|
||||
Session session, {
|
||||
required UuidValue owner,
|
||||
required UuidValue agent,
|
||||
}) => _answer('newAgentToken', () => throw UnimplementedError());
|
||||
|
||||
@override
|
||||
Future<void> revokeAgentToken(
|
||||
Session session, {
|
||||
required UuidValue owner,
|
||||
required UuidValue agent,
|
||||
}) => _answer('revokeAgentToken', () {});
|
||||
|
||||
@override
|
||||
Future<void> deleteAgent(
|
||||
Session session, {
|
||||
required UuidValue owner,
|
||||
required UuidValue agent,
|
||||
}) => _answer('deleteAgent', () {});
|
||||
|
||||
@override
|
||||
Future<UuidValue> agentWithToken(
|
||||
Session session, {
|
||||
required String token,
|
||||
required String network,
|
||||
}) => _answer('agentWithToken', () {
|
||||
networks.add(network);
|
||||
return agentTokens[token] ??
|
||||
(throw AgentRefusedException(reason: AgentRefusal.notValid));
|
||||
});
|
||||
|
||||
@override
|
||||
Future<void> setAgentKeys(
|
||||
Session session, {
|
||||
required UuidValue agent,
|
||||
required ByteData encryption,
|
||||
required ByteData signing,
|
||||
}) => _answer('setAgentKeys', () {});
|
||||
|
||||
@override
|
||||
Future<Map<UuidValue, UuidValue>> agentOwners(
|
||||
Session session,
|
||||
Set<UuidValue> people,
|
||||
) => _answer(
|
||||
'agentOwners',
|
||||
() => {
|
||||
for (final person in people) person: ?agentOwnerOf[person],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
|
|
@ -1062,6 +1151,68 @@ void main() {
|
|||
});
|
||||
});
|
||||
|
||||
group("agents, by asking the register (Halkyon's ADR 0044, D45)", () {
|
||||
test(
|
||||
'are made at the register, the device network passed on',
|
||||
() async {
|
||||
final made = await endpoints.agents.create(
|
||||
asBen(),
|
||||
name: 'Claude',
|
||||
handle: 'claude',
|
||||
);
|
||||
|
||||
expect(register.asked, ['createAgent']);
|
||||
expect(register.agentOwnerOf[made.agent.id], ben);
|
||||
expect(register.networks, hasLength(1));
|
||||
},
|
||||
);
|
||||
|
||||
test('sign in with the token the register knows: a person here with '
|
||||
"an agent's scope, for a day", () async {
|
||||
final made = await endpoints.agents.create(
|
||||
asBen(),
|
||||
name: 'Claude',
|
||||
handle: 'claude',
|
||||
);
|
||||
final success = await endpoints.agentSignIn.signIn(
|
||||
notSignedIn(),
|
||||
token: made.token,
|
||||
);
|
||||
|
||||
expect(success.authUserId, made.agent.id);
|
||||
expect(await scopesOf(made.agent.id), {
|
||||
identified.name,
|
||||
agentScope.name,
|
||||
});
|
||||
final row = await ServerSideSession.db.findFirstRow(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(made.agent.id),
|
||||
);
|
||||
expect(row!.expiresAt, isNotNull);
|
||||
expect(
|
||||
row.expiresAt!.difference(row.createdAt),
|
||||
lessThanOrEqualTo(agentSessionLifetime),
|
||||
);
|
||||
});
|
||||
|
||||
test('sign nobody in with a token the register refuses', () async {
|
||||
await expectLater(
|
||||
endpoints.agentSignIn.signIn(
|
||||
notSignedIn(),
|
||||
token: '${agentTokenPrefix}none',
|
||||
),
|
||||
throwsA(
|
||||
isA<AgentRefusedException>().having(
|
||||
(error) => error.reason,
|
||||
'reason',
|
||||
AgentRefusal.notValid,
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(await ServerSideSession.db.count(session), 2);
|
||||
});
|
||||
});
|
||||
|
||||
group('passwords, by asking the register (ADR 0017)', () {
|
||||
test('sign the account the register proved in here, made a person '
|
||||
'here the first time, the device network passed on', () async {
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ void useBase(
|
|||
usePasswordKeys(pod, null);
|
||||
useOfferedWays(pod, null);
|
||||
useSimpleLoginApp(pod, null);
|
||||
useAgents(pod, null);
|
||||
AuthServices.set(
|
||||
tokenManagerBuilders: tokenManagerBuilders,
|
||||
identityProviderBuilders: identityProviderBuilders(pod),
|
||||
|
|
|
|||
|
|
@ -17,6 +17,10 @@ import 'dart:io' as _idi;
|
|||
import 'dart:typed_data' as _idt;
|
||||
import 'package:halkyon_base_server/src/generated/admission/waiting_state.dart'
|
||||
as _iuvj70hb;
|
||||
import 'package:halkyon_base_server/src/generated/agent/agent_info.dart'
|
||||
as _idcilaj7;
|
||||
import 'package:halkyon_base_server/src/generated/agent/agent_made.dart'
|
||||
as _iu2mzn2s;
|
||||
import 'package:halkyon_base_server/src/generated/assistant/assistant_answer.dart'
|
||||
as _i07tvte4;
|
||||
import 'package:halkyon_base_server/src/generated/assistant/assistant_grantee.dart'
|
||||
|
|
@ -239,6 +243,10 @@ void withServerpod(
|
|||
class TestEndpoints {
|
||||
late final _WaitingEndpoint waiting;
|
||||
|
||||
late final _AgentSignInEndpoint agentSignIn;
|
||||
|
||||
late final _AgentsEndpoint agents;
|
||||
|
||||
late final _AssistantEndpoint assistant;
|
||||
|
||||
late final _AssistantOperatorEndpoint assistantOperator;
|
||||
|
|
@ -285,6 +293,14 @@ class _InternalTestEndpoints extends TestEndpoints
|
|||
endpoints,
|
||||
serializationManager,
|
||||
);
|
||||
agentSignIn = _AgentSignInEndpoint(
|
||||
endpoints,
|
||||
serializationManager,
|
||||
);
|
||||
agents = _AgentsEndpoint(
|
||||
endpoints,
|
||||
serializationManager,
|
||||
);
|
||||
assistant = _AssistantEndpoint(
|
||||
endpoints,
|
||||
serializationManager,
|
||||
|
|
@ -460,6 +476,252 @@ class _WaitingEndpoint {
|
|||
}
|
||||
}
|
||||
|
||||
class _AgentSignInEndpoint {
|
||||
_AgentSignInEndpoint(
|
||||
this._endpointDispatch,
|
||||
this._serializationManager,
|
||||
);
|
||||
|
||||
final _is.EndpointDispatch _endpointDispatch;
|
||||
|
||||
final _is.SerializationManager _serializationManager;
|
||||
|
||||
_ida.Future<_iacs.AuthSuccess> signIn(
|
||||
_ist.TestSessionBuilder sessionBuilder, {
|
||||
required String token,
|
||||
}) async {
|
||||
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
|
||||
var _localUniqueSession =
|
||||
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
|
||||
endpoint: 'agentSignIn',
|
||||
method: 'signIn',
|
||||
);
|
||||
try {
|
||||
var _localCallContext = await _endpointDispatch.getMethodCallContext(
|
||||
createSessionCallback: (_) => _localUniqueSession,
|
||||
endpointPath: 'agentSignIn',
|
||||
methodName: 'signIn',
|
||||
parameters: _ist.testObjectToJson({'token': token}),
|
||||
serializationManager: _serializationManager,
|
||||
);
|
||||
var _localReturnValue =
|
||||
await (_localCallContext.method.call(
|
||||
_localUniqueSession,
|
||||
_localCallContext.arguments,
|
||||
)
|
||||
as _ida.Future<_iacs.AuthSuccess>);
|
||||
return _localReturnValue;
|
||||
} finally {
|
||||
await _localUniqueSession.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _AgentsEndpoint {
|
||||
_AgentsEndpoint(
|
||||
this._endpointDispatch,
|
||||
this._serializationManager,
|
||||
);
|
||||
|
||||
final _is.EndpointDispatch _endpointDispatch;
|
||||
|
||||
final _is.SerializationManager _serializationManager;
|
||||
|
||||
_ida.Future<_iu2mzn2s.AgentMade> create(
|
||||
_ist.TestSessionBuilder sessionBuilder, {
|
||||
required String name,
|
||||
required String handle,
|
||||
}) async {
|
||||
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
|
||||
var _localUniqueSession =
|
||||
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
|
||||
endpoint: 'agents',
|
||||
method: 'create',
|
||||
);
|
||||
try {
|
||||
var _localCallContext = await _endpointDispatch.getMethodCallContext(
|
||||
createSessionCallback: (_) => _localUniqueSession,
|
||||
endpointPath: 'agents',
|
||||
methodName: 'create',
|
||||
parameters: _ist.testObjectToJson({
|
||||
'name': name,
|
||||
'handle': handle,
|
||||
}),
|
||||
serializationManager: _serializationManager,
|
||||
);
|
||||
var _localReturnValue =
|
||||
await (_localCallContext.method.call(
|
||||
_localUniqueSession,
|
||||
_localCallContext.arguments,
|
||||
)
|
||||
as _ida.Future<_iu2mzn2s.AgentMade>);
|
||||
return _localReturnValue;
|
||||
} finally {
|
||||
await _localUniqueSession.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_ida.Future<List<_idcilaj7.AgentInfo>> mine(
|
||||
_ist.TestSessionBuilder sessionBuilder,
|
||||
) async {
|
||||
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
|
||||
var _localUniqueSession =
|
||||
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
|
||||
endpoint: 'agents',
|
||||
method: 'mine',
|
||||
);
|
||||
try {
|
||||
var _localCallContext = await _endpointDispatch.getMethodCallContext(
|
||||
createSessionCallback: (_) => _localUniqueSession,
|
||||
endpointPath: 'agents',
|
||||
methodName: 'mine',
|
||||
parameters: _ist.testObjectToJson({}),
|
||||
serializationManager: _serializationManager,
|
||||
);
|
||||
var _localReturnValue =
|
||||
await (_localCallContext.method.call(
|
||||
_localUniqueSession,
|
||||
_localCallContext.arguments,
|
||||
)
|
||||
as _ida.Future<List<_idcilaj7.AgentInfo>>);
|
||||
return _localReturnValue;
|
||||
} finally {
|
||||
await _localUniqueSession.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_ida.Future<_iu2mzn2s.AgentMade> newToken(
|
||||
_ist.TestSessionBuilder sessionBuilder, {
|
||||
required _is.UuidValue agent,
|
||||
}) async {
|
||||
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
|
||||
var _localUniqueSession =
|
||||
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
|
||||
endpoint: 'agents',
|
||||
method: 'newToken',
|
||||
);
|
||||
try {
|
||||
var _localCallContext = await _endpointDispatch.getMethodCallContext(
|
||||
createSessionCallback: (_) => _localUniqueSession,
|
||||
endpointPath: 'agents',
|
||||
methodName: 'newToken',
|
||||
parameters: _ist.testObjectToJson({'agent': agent}),
|
||||
serializationManager: _serializationManager,
|
||||
);
|
||||
var _localReturnValue =
|
||||
await (_localCallContext.method.call(
|
||||
_localUniqueSession,
|
||||
_localCallContext.arguments,
|
||||
)
|
||||
as _ida.Future<_iu2mzn2s.AgentMade>);
|
||||
return _localReturnValue;
|
||||
} finally {
|
||||
await _localUniqueSession.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_ida.Future<void> revoke(
|
||||
_ist.TestSessionBuilder sessionBuilder, {
|
||||
required _is.UuidValue agent,
|
||||
}) async {
|
||||
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
|
||||
var _localUniqueSession =
|
||||
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
|
||||
endpoint: 'agents',
|
||||
method: 'revoke',
|
||||
);
|
||||
try {
|
||||
var _localCallContext = await _endpointDispatch.getMethodCallContext(
|
||||
createSessionCallback: (_) => _localUniqueSession,
|
||||
endpointPath: 'agents',
|
||||
methodName: 'revoke',
|
||||
parameters: _ist.testObjectToJson({'agent': agent}),
|
||||
serializationManager: _serializationManager,
|
||||
);
|
||||
var _localReturnValue =
|
||||
await (_localCallContext.method.call(
|
||||
_localUniqueSession,
|
||||
_localCallContext.arguments,
|
||||
)
|
||||
as _ida.Future<void>);
|
||||
return _localReturnValue;
|
||||
} finally {
|
||||
await _localUniqueSession.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_ida.Future<void> delete(
|
||||
_ist.TestSessionBuilder sessionBuilder, {
|
||||
required _is.UuidValue agent,
|
||||
}) async {
|
||||
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
|
||||
var _localUniqueSession =
|
||||
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
|
||||
endpoint: 'agents',
|
||||
method: 'delete',
|
||||
);
|
||||
try {
|
||||
var _localCallContext = await _endpointDispatch.getMethodCallContext(
|
||||
createSessionCallback: (_) => _localUniqueSession,
|
||||
endpointPath: 'agents',
|
||||
methodName: 'delete',
|
||||
parameters: _ist.testObjectToJson({'agent': agent}),
|
||||
serializationManager: _serializationManager,
|
||||
);
|
||||
var _localReturnValue =
|
||||
await (_localCallContext.method.call(
|
||||
_localUniqueSession,
|
||||
_localCallContext.arguments,
|
||||
)
|
||||
as _ida.Future<void>);
|
||||
return _localReturnValue;
|
||||
} finally {
|
||||
await _localUniqueSession.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_ida.Future<void> setKeys(
|
||||
_ist.TestSessionBuilder sessionBuilder, {
|
||||
required _idt.ByteData encryption,
|
||||
required _idt.ByteData signing,
|
||||
}) async {
|
||||
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
|
||||
var _localUniqueSession =
|
||||
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
|
||||
endpoint: 'agents',
|
||||
method: 'setKeys',
|
||||
);
|
||||
try {
|
||||
var _localCallContext = await _endpointDispatch.getMethodCallContext(
|
||||
createSessionCallback: (_) => _localUniqueSession,
|
||||
endpointPath: 'agents',
|
||||
methodName: 'setKeys',
|
||||
parameters: _ist.testObjectToJson({
|
||||
'encryption': encryption,
|
||||
'signing': signing,
|
||||
}),
|
||||
serializationManager: _serializationManager,
|
||||
);
|
||||
var _localReturnValue =
|
||||
await (_localCallContext.method.call(
|
||||
_localUniqueSession,
|
||||
_localCallContext.arguments,
|
||||
)
|
||||
as _ida.Future<void>);
|
||||
return _localReturnValue;
|
||||
} finally {
|
||||
await _localUniqueSession.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _AssistantEndpoint {
|
||||
_AssistantEndpoint(
|
||||
this._endpointDispatch,
|
||||
|
|
|
|||
Loading…
Reference in a new issue