@actagent/tlon 2026.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +5 -0
  2. package/actagent.plugin.json +15 -0
  3. package/api.ts +17 -0
  4. package/channel-plugin-api.ts +2 -0
  5. package/doctor-contract-api.ts +2 -0
  6. package/index.ts +17 -0
  7. package/npm-shrinkwrap.json +861 -0
  8. package/package.json +87 -0
  9. package/runtime-api.ts +17 -0
  10. package/setup-api.ts +3 -0
  11. package/setup-entry.ts +10 -0
  12. package/src/account-fields.ts +32 -0
  13. package/src/channel.message-adapter.test.ts +147 -0
  14. package/src/channel.runtime.ts +260 -0
  15. package/src/channel.ts +193 -0
  16. package/src/config-schema.ts +55 -0
  17. package/src/core.test.ts +299 -0
  18. package/src/doctor-contract.ts +10 -0
  19. package/src/doctor.test.ts +47 -0
  20. package/src/doctor.ts +11 -0
  21. package/src/logger-runtime.ts +2 -0
  22. package/src/monitor/approval-runtime.ts +364 -0
  23. package/src/monitor/approval.test.ts +34 -0
  24. package/src/monitor/approval.ts +283 -0
  25. package/src/monitor/authorization.ts +31 -0
  26. package/src/monitor/cites.ts +55 -0
  27. package/src/monitor/discovery.ts +69 -0
  28. package/src/monitor/history.ts +227 -0
  29. package/src/monitor/index.ts +1531 -0
  30. package/src/monitor/media.test.ts +81 -0
  31. package/src/monitor/media.ts +157 -0
  32. package/src/monitor/processed-messages.test.ts +59 -0
  33. package/src/monitor/processed-messages.ts +90 -0
  34. package/src/monitor/settings-helpers.test.ts +114 -0
  35. package/src/monitor/settings-helpers.ts +151 -0
  36. package/src/monitor/utils.ts +403 -0
  37. package/src/runtime.ts +10 -0
  38. package/src/security.test.ts +654 -0
  39. package/src/session-route.ts +41 -0
  40. package/src/settings.ts +391 -0
  41. package/src/setup-core.ts +232 -0
  42. package/src/setup-surface.ts +98 -0
  43. package/src/targets.ts +103 -0
  44. package/src/tlon-api.test.ts +573 -0
  45. package/src/tlon-api.ts +390 -0
  46. package/src/types.ts +161 -0
  47. package/src/urbit/auth.ssrf.test.ts +46 -0
  48. package/src/urbit/auth.ts +49 -0
  49. package/src/urbit/base-url.test.ts +49 -0
  50. package/src/urbit/base-url.ts +62 -0
  51. package/src/urbit/channel-ops.test.ts +37 -0
  52. package/src/urbit/channel-ops.ts +150 -0
  53. package/src/urbit/context.ts +51 -0
  54. package/src/urbit/errors.ts +52 -0
  55. package/src/urbit/fetch.ts +43 -0
  56. package/src/urbit/foreigns.ts +49 -0
  57. package/src/urbit/send.test.ts +84 -0
  58. package/src/urbit/send.ts +229 -0
  59. package/src/urbit/sse-client.test.ts +262 -0
  60. package/src/urbit/sse-client.ts +507 -0
  61. package/src/urbit/story.ts +327 -0
  62. package/src/urbit/upload.test.ts +156 -0
  63. package/src/urbit/upload.ts +60 -0
  64. package/test-api.ts +2 -0
  65. package/tsconfig.json +16 -0
package/src/channel.ts ADDED
@@ -0,0 +1,193 @@
1
+ // Tlon plugin module implements channel behavior.
2
+ import { describeAccountSnapshot } from "actagent/plugin-sdk/account-helpers";
3
+ import { DEFAULT_ACCOUNT_ID } from "actagent/plugin-sdk/account-id";
4
+ import { createHybridChannelConfigAdapter } from "actagent/plugin-sdk/channel-config-helpers";
5
+ import { createChatChannelPlugin, type ChannelPlugin } from "actagent/plugin-sdk/channel-core";
6
+ import { createChannelMessageAdapterFromOutbound } from "actagent/plugin-sdk/channel-outbound";
7
+ import { createRuntimeOutboundDelegates } from "actagent/plugin-sdk/channel-outbound";
8
+ import type { ChannelOutboundAdapter } from "actagent/plugin-sdk/channel-send-result";
9
+ import { createLazyRuntimeModule } from "actagent/plugin-sdk/lazy-runtime";
10
+ import {
11
+ createComputedAccountStatusAdapter,
12
+ createDefaultChannelRuntimeState,
13
+ } from "actagent/plugin-sdk/status-helpers";
14
+ import { tlonChannelConfigSchema } from "./config-schema.js";
15
+ import { tlonDoctor } from "./doctor.js";
16
+ import { resolveTlonOutboundSessionRoute } from "./session-route.js";
17
+ import { createTlonSetupWizardBase, tlonSetupAdapter } from "./setup-core.js";
18
+ import {
19
+ formatTargetHint,
20
+ normalizeShip,
21
+ parseTlonTarget,
22
+ resolveTlonOutboundTarget,
23
+ } from "./targets.js";
24
+ import { listTlonAccountIds, resolveTlonAccount } from "./types.js";
25
+
26
+ const TLON_CHANNEL_ID = "tlon" as const;
27
+
28
+ const loadTlonChannelRuntime = createLazyRuntimeModule(() => import("./channel.runtime.js"));
29
+
30
+ const tlonSetupWizardProxy = createTlonSetupWizardBase({
31
+ resolveConfigured: async ({ cfg, accountId }) =>
32
+ await (
33
+ await loadTlonChannelRuntime()
34
+ ).tlonSetupWizard.status.resolveConfigured({
35
+ cfg,
36
+ accountId,
37
+ }),
38
+ resolveStatusLines: async ({ cfg, accountId, configured }) =>
39
+ (await (
40
+ await loadTlonChannelRuntime()
41
+ ).tlonSetupWizard.status.resolveStatusLines?.({
42
+ cfg,
43
+ accountId,
44
+ configured,
45
+ })) ?? [],
46
+ finalize: async (params) =>
47
+ await (
48
+ await loadTlonChannelRuntime()
49
+ ).tlonSetupWizard.finalize!(params),
50
+ }) satisfies NonNullable<ChannelPlugin["setupWizard"]>;
51
+
52
+ const tlonConfigAdapter = createHybridChannelConfigAdapter({
53
+ sectionKey: TLON_CHANNEL_ID,
54
+ listAccountIds: listTlonAccountIds,
55
+ resolveAccount: resolveTlonAccount,
56
+ defaultAccountId: () => DEFAULT_ACCOUNT_ID,
57
+ clearBaseFields: ["ship", "code", "url", "name"],
58
+ preserveSectionOnDefaultDelete: true,
59
+ resolveAllowFrom: (account) => account.dmAllowlist,
60
+ formatAllowFrom: (allowFrom) =>
61
+ allowFrom.map((entry) => normalizeShip(String(entry))).filter(Boolean),
62
+ });
63
+
64
+ const tlonChannelOutbound: ChannelOutboundAdapter = {
65
+ deliveryMode: "direct",
66
+ textChunkLimit: 10000,
67
+ resolveTarget: ({ to }) => resolveTlonOutboundTarget(to),
68
+ deliveryCapabilities: {
69
+ durableFinal: {
70
+ text: true,
71
+ media: true,
72
+ replyTo: true,
73
+ thread: true,
74
+ messageSendingHooks: true,
75
+ },
76
+ },
77
+ ...createRuntimeOutboundDelegates({
78
+ getRuntime: loadTlonChannelRuntime,
79
+ sendText: { resolve: (runtime) => runtime.tlonRuntimeOutbound.sendText },
80
+ sendMedia: { resolve: (runtime) => runtime.tlonRuntimeOutbound.sendMedia },
81
+ }),
82
+ };
83
+
84
+ const tlonMessageAdapter = createChannelMessageAdapterFromOutbound({
85
+ id: TLON_CHANNEL_ID,
86
+ outbound: tlonChannelOutbound,
87
+ });
88
+
89
+ export const tlonPlugin = createChatChannelPlugin({
90
+ base: {
91
+ id: TLON_CHANNEL_ID,
92
+ meta: {
93
+ id: TLON_CHANNEL_ID,
94
+ label: "Tlon",
95
+ selectionLabel: "Tlon (Urbit)",
96
+ docsPath: "/channels/tlon",
97
+ docsLabel: "tlon",
98
+ blurb: "Decentralized messaging on Urbit",
99
+ aliases: ["urbit"],
100
+ order: 90,
101
+ },
102
+ capabilities: {
103
+ chatTypes: ["direct", "group", "thread"],
104
+ media: true,
105
+ reply: true,
106
+ threads: true,
107
+ },
108
+ setup: tlonSetupAdapter,
109
+ setupWizard: tlonSetupWizardProxy,
110
+ reload: { configPrefixes: ["channels.tlon"] },
111
+ configSchema: tlonChannelConfigSchema,
112
+ config: {
113
+ ...tlonConfigAdapter,
114
+ isConfigured: (account) => account.configured,
115
+ describeAccount: (account) =>
116
+ describeAccountSnapshot({
117
+ account,
118
+ configured: account.configured,
119
+ extra: {
120
+ ship: account.ship,
121
+ url: account.url,
122
+ },
123
+ }),
124
+ },
125
+ doctor: tlonDoctor,
126
+ messaging: {
127
+ targetPrefixes: ["tlon"],
128
+ normalizeTarget: (target) => {
129
+ const parsed = parseTlonTarget(target);
130
+ if (!parsed) {
131
+ return target.trim();
132
+ }
133
+ if (parsed.kind === "dm") {
134
+ return parsed.ship;
135
+ }
136
+ return parsed.nest;
137
+ },
138
+ targetResolver: {
139
+ looksLikeId: (target) => Boolean(parseTlonTarget(target)),
140
+ hint: formatTargetHint(),
141
+ },
142
+ resolveOutboundSessionRoute: (params) => resolveTlonOutboundSessionRoute(params),
143
+ },
144
+ message: tlonMessageAdapter,
145
+ status: createComputedAccountStatusAdapter<ReturnType<typeof resolveTlonAccount>>({
146
+ defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),
147
+ collectStatusIssues: (accounts) => {
148
+ return accounts.flatMap((account) => {
149
+ if (!account.configured) {
150
+ return [
151
+ {
152
+ channel: TLON_CHANNEL_ID,
153
+ accountId: account.accountId,
154
+ kind: "config",
155
+ message: "Account not configured (missing ship, code, or url)",
156
+ },
157
+ ];
158
+ }
159
+ return [];
160
+ });
161
+ },
162
+ buildChannelSummary: ({ snapshot }) => {
163
+ const s = snapshot as { configured?: boolean; ship?: string; url?: string };
164
+ return {
165
+ configured: s.configured ?? false,
166
+ ship: s.ship ?? null,
167
+ url: s.url ?? null,
168
+ };
169
+ },
170
+ probeAccount: async ({ account }) => {
171
+ if (!account.configured || !account.ship || !account.url || !account.code) {
172
+ return { ok: false, error: "Not configured" };
173
+ }
174
+ return await (await loadTlonChannelRuntime()).probeTlonAccount(account as never);
175
+ },
176
+ resolveAccountSnapshot: ({ account }) => ({
177
+ accountId: account.accountId,
178
+ name: account.name ?? undefined,
179
+ enabled: account.enabled,
180
+ configured: account.configured,
181
+ extra: {
182
+ ship: account.ship,
183
+ url: account.url,
184
+ },
185
+ }),
186
+ }),
187
+ gateway: {
188
+ startAccount: async (ctx) =>
189
+ await (await loadTlonChannelRuntime()).startTlonGatewayAccount(ctx),
190
+ },
191
+ },
192
+ outbound: tlonChannelOutbound,
193
+ });
@@ -0,0 +1,55 @@
1
+ // Tlon helper module supports config schema behavior.
2
+ import { buildChannelConfigSchema } from "actagent/plugin-sdk/channel-config-schema";
3
+ import { z } from "zod";
4
+
5
+ const ShipSchema = z.string().min(1);
6
+ const ChannelNestSchema = z.string().min(1);
7
+
8
+ const TlonChannelRuleSchema = z.object({
9
+ mode: z.enum(["restricted", "open"]).optional(),
10
+ allowedShips: z.array(ShipSchema).optional(),
11
+ });
12
+
13
+ export const TlonAuthorizationSchema = z.object({
14
+ channelRules: z.record(z.string(), TlonChannelRuleSchema).optional(),
15
+ });
16
+
17
+ const TlonNetworkSchema = z
18
+ .object({
19
+ dangerouslyAllowPrivateNetwork: z.boolean().optional(),
20
+ })
21
+ .strict()
22
+ .optional();
23
+
24
+ const tlonCommonConfigFields = {
25
+ name: z.string().optional(),
26
+ enabled: z.boolean().optional(),
27
+ ship: ShipSchema.optional(),
28
+ url: z.string().optional(),
29
+ code: z.string().optional(),
30
+ network: TlonNetworkSchema,
31
+ groupChannels: z.array(ChannelNestSchema).optional(),
32
+ dmAllowlist: z.array(ShipSchema).optional(),
33
+ groupInviteAllowlist: z.array(ShipSchema).optional(),
34
+ autoDiscoverChannels: z.boolean().optional(),
35
+ showModelSignature: z.boolean().optional(),
36
+ responsePrefix: z.string().optional(),
37
+ // Auto-accept settings
38
+ autoAcceptDmInvites: z.boolean().optional(), // Auto-accept DMs from ships in dmAllowlist
39
+ autoAcceptGroupInvites: z.boolean().optional(), // Auto-accept all group invites
40
+ // Owner ship for approval system
41
+ ownerShip: ShipSchema.optional(), // Ship that receives approval requests and can approve/deny
42
+ } satisfies z.ZodRawShape;
43
+
44
+ const TlonAccountSchema = z.object({
45
+ ...tlonCommonConfigFields,
46
+ });
47
+
48
+ export const TlonConfigSchema = z.object({
49
+ ...tlonCommonConfigFields,
50
+ authorization: TlonAuthorizationSchema.optional(),
51
+ defaultAuthorizedShips: z.array(ShipSchema).optional(),
52
+ accounts: z.record(z.string(), TlonAccountSchema).optional(),
53
+ });
54
+
55
+ export const tlonChannelConfigSchema = buildChannelConfigSchema(TlonConfigSchema);
@@ -0,0 +1,299 @@
1
+ // Tlon tests cover core plugin behavior.
2
+ import {
3
+ createPluginSetupWizardConfigure,
4
+ createPluginSetupWizardStatus,
5
+ createTestWizardPrompter,
6
+ runSetupWizardConfigure,
7
+ } from "actagent/plugin-sdk/plugin-test-runtime";
8
+ import type { WizardPrompter } from "actagent/plugin-sdk/plugin-test-runtime";
9
+ import { describe, expect, it, vi } from "vitest";
10
+ import type { ACTAgentConfig } from "../api.js";
11
+ import { TlonAuthorizationSchema, TlonConfigSchema } from "./config-schema.js";
12
+ import { tlonSetupWizard } from "./setup-surface.js";
13
+ import { normalizeShip, resolveTlonOutboundTarget } from "./targets.js";
14
+ import { listTlonAccountIds, resolveTlonAccount } from "./types.js";
15
+
16
+ const tlonTestPlugin = {
17
+ id: "tlon",
18
+ meta: { label: "Tlon" },
19
+ setupWizard: tlonSetupWizard,
20
+ config: {
21
+ listAccountIds: listTlonAccountIds,
22
+ defaultAccountId: () => "default",
23
+ resolveAllowFrom: ({ cfg, accountId }: { cfg: ACTAgentConfig; accountId?: string | null }) =>
24
+ resolveTlonAccount(cfg, accountId).dmAllowlist,
25
+ formatAllowFrom: ({
26
+ allowFrom,
27
+ }: {
28
+ cfg: ACTAgentConfig;
29
+ allowFrom: Array<string | number> | undefined | null;
30
+ }) => {
31
+ const entries: string[] = [];
32
+ for (const entry of allowFrom ?? []) {
33
+ const normalized = normalizeShip(String(entry));
34
+ if (normalized) {
35
+ entries.push(normalized);
36
+ }
37
+ }
38
+ return entries;
39
+ },
40
+ },
41
+ setup: {
42
+ resolveAccountId: ({ accountId }: { cfg: ACTAgentConfig; accountId?: string | null }) =>
43
+ accountId ?? "default",
44
+ },
45
+ };
46
+
47
+ const tlonConfigure = createPluginSetupWizardConfigure(tlonTestPlugin);
48
+ const tlonStatus = createPluginSetupWizardStatus(tlonTestPlugin);
49
+
50
+ describe("tlon core", () => {
51
+ it("formats dm allowlist entries through the shared hybrid adapter", () => {
52
+ expect(
53
+ tlonTestPlugin.config.formatAllowFrom?.({
54
+ cfg: {} as ACTAgentConfig,
55
+ allowFrom: ["zod", " ~nec "],
56
+ }),
57
+ ).toEqual(["~zod", "~nec"]);
58
+ });
59
+
60
+ it("returns an empty dm allowlist when the default account is unconfigured", () => {
61
+ expect(
62
+ tlonTestPlugin.config.resolveAllowFrom?.({
63
+ cfg: {} as ACTAgentConfig,
64
+ accountId: "default",
65
+ }),
66
+ ).toStrictEqual([]);
67
+ });
68
+
69
+ it("resolves dm allowlist from the default account", () => {
70
+ expect(
71
+ tlonTestPlugin.config.resolveAllowFrom?.({
72
+ cfg: {
73
+ channels: {
74
+ tlon: {
75
+ ship: "~sampel-palnet",
76
+ url: "https://urbit.example.com",
77
+ code: "lidlut-tabwed-pillex-ridrup",
78
+ dmAllowlist: ["~zod"],
79
+ },
80
+ },
81
+ } as ACTAgentConfig,
82
+ accountId: "default",
83
+ }),
84
+ ).toEqual(["~zod"]);
85
+ });
86
+
87
+ it("accepts channelRules with string keys", () => {
88
+ const parsed = TlonAuthorizationSchema.parse({
89
+ channelRules: {
90
+ "chat/~zod/test": {
91
+ mode: "open",
92
+ allowedShips: ["~zod"],
93
+ },
94
+ },
95
+ });
96
+
97
+ expect(parsed.channelRules?.["chat/~zod/test"]?.mode).toBe("open");
98
+ });
99
+
100
+ it("accepts accounts with string keys", () => {
101
+ const parsed = TlonConfigSchema.parse({
102
+ accounts: {
103
+ primary: {
104
+ ship: "~zod",
105
+ url: "https://example.com",
106
+ code: "code-123",
107
+ },
108
+ },
109
+ });
110
+
111
+ expect(parsed.accounts?.primary?.ship).toBe("~zod");
112
+ });
113
+
114
+ it("exposes group invite allowlists in channel config schema", () => {
115
+ expect(TlonConfigSchema.parse({ groupInviteAllowlist: ["~zod"] }).groupInviteAllowlist).toEqual(
116
+ ["~zod"],
117
+ );
118
+ expect(
119
+ TlonConfigSchema.parse({
120
+ accounts: { primary: { groupInviteAllowlist: ["~nec"] } },
121
+ }).accounts?.primary?.groupInviteAllowlist,
122
+ ).toEqual(["~nec"]);
123
+ });
124
+
125
+ it("configures ship, auth, and discovery settings", async () => {
126
+ const prompter = createTestWizardPrompter({
127
+ text: vi.fn(async ({ message }: { message: string }) => {
128
+ if (message === "Ship name") {
129
+ return "sampel-palnet";
130
+ }
131
+ if (message === "Ship URL") {
132
+ return "https://urbit.example.com";
133
+ }
134
+ if (message === "Login code") {
135
+ return "lidlut-tabwed-pillex-ridrup";
136
+ }
137
+ if (message === "Group channels (comma-separated)") {
138
+ return "chat/~host-ship/general, chat/~host-ship/support";
139
+ }
140
+ if (message === "DM allowlist (comma-separated ship names)") {
141
+ return "~zod, nec";
142
+ }
143
+ throw new Error(`Unexpected prompt: ${message}`);
144
+ }) as WizardPrompter["text"],
145
+ confirm: vi.fn(async ({ message }: { message: string }) => {
146
+ if (message === "Add group channels manually? (optional)") {
147
+ return true;
148
+ }
149
+ if (message === "Restrict DMs with an allowlist?") {
150
+ return true;
151
+ }
152
+ if (message === "Enable auto-discovery of group channels?") {
153
+ return true;
154
+ }
155
+ return false;
156
+ }),
157
+ });
158
+
159
+ const result = await runSetupWizardConfigure({
160
+ configure: tlonConfigure,
161
+ cfg: {} as ACTAgentConfig,
162
+ prompter,
163
+ options: {},
164
+ });
165
+
166
+ expect(result.accountId).toBe("default");
167
+ expect(result.cfg.channels?.tlon?.enabled).toBe(true);
168
+ expect(result.cfg.channels?.tlon?.ship).toBe("~sampel-palnet");
169
+ expect(result.cfg.channels?.tlon?.url).toBe("https://urbit.example.com");
170
+ expect(result.cfg.channels?.tlon?.code).toBe("lidlut-tabwed-pillex-ridrup");
171
+ expect(result.cfg.channels?.tlon?.groupChannels).toEqual([
172
+ "chat/~host-ship/general",
173
+ "chat/~host-ship/support",
174
+ ]);
175
+ expect(result.cfg.channels?.tlon?.dmAllowlist).toEqual(["~zod", "~nec"]);
176
+ expect(result.cfg.channels?.tlon?.autoDiscoverChannels).toBe(true);
177
+ expect(result.cfg.channels?.tlon?.network?.dangerouslyAllowPrivateNetwork).toBe(false);
178
+ });
179
+
180
+ it("resolves dm targets to normalized ships", () => {
181
+ expect(resolveTlonOutboundTarget("dm/sampel-palnet")).toEqual({
182
+ ok: true,
183
+ to: "~sampel-palnet",
184
+ });
185
+ });
186
+
187
+ it("resolves group targets to canonical chat nests", () => {
188
+ expect(resolveTlonOutboundTarget("group:host-ship/general")).toEqual({
189
+ ok: true,
190
+ to: "chat/~host-ship/general",
191
+ });
192
+ });
193
+
194
+ it("returns a helpful error for invalid targets", () => {
195
+ const resolved = resolveTlonOutboundTarget("group:bad-target");
196
+ expect(resolved.ok).toBe(false);
197
+ if (resolved.ok) {
198
+ throw new Error("expected invalid target");
199
+ }
200
+ expect(resolved.error.message).toMatch(/invalid tlon target/i);
201
+ });
202
+
203
+ it("lists named accounts and the implicit default account", () => {
204
+ const cfg = {
205
+ channels: {
206
+ tlon: {
207
+ ship: "~zod",
208
+ accounts: {
209
+ Work: { ship: "~bus" },
210
+ alerts: { ship: "~nec" },
211
+ },
212
+ },
213
+ },
214
+ } as ACTAgentConfig;
215
+
216
+ expect(listTlonAccountIds(cfg)).toEqual(["alerts", "default", "work"]);
217
+ });
218
+
219
+ it("merges named account config over channel defaults", () => {
220
+ const resolved = resolveTlonAccount(
221
+ {
222
+ channels: {
223
+ tlon: {
224
+ name: "Base",
225
+ ship: "~zod",
226
+ url: "https://urbit.example.com",
227
+ code: "base-code",
228
+ dmAllowlist: ["~nec"],
229
+ groupInviteAllowlist: ["~bus"],
230
+ defaultAuthorizedShips: ["~marzod"],
231
+ accounts: {
232
+ Work: {
233
+ name: "Work",
234
+ code: "work-code",
235
+ dmAllowlist: ["~rovnys"],
236
+ },
237
+ },
238
+ },
239
+ },
240
+ } as ACTAgentConfig,
241
+ "work",
242
+ );
243
+
244
+ expect(resolved.accountId).toBe("work");
245
+ expect(resolved.name).toBe("Work");
246
+ expect(resolved.ship).toBe("~zod");
247
+ expect(resolved.url).toBe("https://urbit.example.com");
248
+ expect(resolved.code).toBe("work-code");
249
+ expect(resolved.dmAllowlist).toEqual(["~rovnys"]);
250
+ expect(resolved.groupInviteAllowlist).toEqual(["~bus"]);
251
+ expect(resolved.defaultAuthorizedShips).toEqual(["~marzod"]);
252
+ expect(resolved.configured).toBe(true);
253
+ });
254
+
255
+ it("keeps the default account on channel-level config only", () => {
256
+ const resolved = resolveTlonAccount(
257
+ {
258
+ channels: {
259
+ tlon: {
260
+ ship: "~zod",
261
+ url: "https://urbit.example.com",
262
+ code: "base-code",
263
+ accounts: {
264
+ default: {
265
+ ship: "~ignored",
266
+ code: "ignored-code",
267
+ },
268
+ },
269
+ },
270
+ },
271
+ } as ACTAgentConfig,
272
+ "default",
273
+ );
274
+
275
+ expect(resolved.ship).toBe("~zod");
276
+ expect(resolved.code).toBe("base-code");
277
+ });
278
+
279
+ it("setup status labels the selected account", async () => {
280
+ const status = await tlonStatus({
281
+ cfg: {
282
+ channels: {
283
+ tlon: {
284
+ ship: "~zod",
285
+ url: "https://urbit.example.com",
286
+ code: "base-code",
287
+ accounts: {
288
+ work: {},
289
+ },
290
+ },
291
+ },
292
+ } as ACTAgentConfig,
293
+ accountOverrides: { tlon: "work" },
294
+ });
295
+
296
+ expect(status.configured).toBe(true);
297
+ expect(status.statusLines).toEqual(["Tlon (work): configured"]);
298
+ });
299
+ });
@@ -0,0 +1,10 @@
1
+ // Tlon plugin module implements doctor contract behavior.
2
+ import { createLegacyPrivateNetworkDoctorContract } from "actagent/plugin-sdk/ssrf-runtime";
3
+
4
+ const contract = createLegacyPrivateNetworkDoctorContract({
5
+ channelKey: "tlon",
6
+ });
7
+
8
+ export const legacyConfigRules = contract.legacyConfigRules;
9
+
10
+ export const normalizeCompatibilityConfig = contract.normalizeCompatibilityConfig;
@@ -0,0 +1,47 @@
1
+ // Tlon tests cover doctor plugin behavior.
2
+ import { describe, expect, it } from "vitest";
3
+ import { tlonDoctor } from "./doctor.js";
4
+
5
+ function getTlonCompatibilityNormalizer(): NonNullable<
6
+ typeof tlonDoctor.normalizeCompatibilityConfig
7
+ > {
8
+ const normalize = tlonDoctor.normalizeCompatibilityConfig;
9
+ if (!normalize) {
10
+ throw new Error("Expected tlon doctor to expose normalizeCompatibilityConfig");
11
+ }
12
+ return normalize;
13
+ }
14
+
15
+ describe("tlon doctor", () => {
16
+ it("normalizes legacy private-network aliases", () => {
17
+ const normalize = getTlonCompatibilityNormalizer();
18
+
19
+ const result = normalize({
20
+ cfg: {
21
+ channels: {
22
+ tlon: {
23
+ allowPrivateNetwork: true,
24
+ accounts: {
25
+ alt: {
26
+ allowPrivateNetwork: false,
27
+ },
28
+ },
29
+ },
30
+ },
31
+ } as never,
32
+ });
33
+
34
+ expect(result.config.channels?.tlon?.network).toEqual({
35
+ dangerouslyAllowPrivateNetwork: true,
36
+ });
37
+ expect(
38
+ (
39
+ result.config.channels?.tlon?.accounts?.alt as
40
+ | { network?: Record<string, unknown> }
41
+ | undefined
42
+ )?.network,
43
+ ).toEqual({
44
+ dangerouslyAllowPrivateNetwork: false,
45
+ });
46
+ });
47
+ });
package/src/doctor.ts ADDED
@@ -0,0 +1,11 @@
1
+ // Tlon plugin module implements doctor behavior.
2
+ import type { ChannelDoctorAdapter } from "actagent/plugin-sdk/channel-contract";
3
+ import {
4
+ legacyConfigRules as TLON_LEGACY_CONFIG_RULES,
5
+ normalizeCompatibilityConfig as normalizeTlonCompatibilityConfig,
6
+ } from "./doctor-contract.js";
7
+
8
+ export const tlonDoctor: ChannelDoctorAdapter = {
9
+ legacyConfigRules: TLON_LEGACY_CONFIG_RULES,
10
+ normalizeCompatibilityConfig: normalizeTlonCompatibilityConfig,
11
+ };
@@ -0,0 +1,2 @@
1
+ // Tlon plugin module implements logger runtime behavior.
2
+ export { createLoggerBackedRuntime } from "actagent/plugin-sdk/runtime";