agents-city 0.4.0 → 0.5.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 (98) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.es.md +240 -11
  3. package/README.md +238 -11
  4. package/benchmarks/latency/fake-claude-cli.mjs +2 -1
  5. package/benchmarks/reception/README.md +16 -0
  6. package/benchmarks/reception/run.mjs +84 -0
  7. package/bin/agents-city.js +1 -0
  8. package/bin/connect +5 -0
  9. package/bin/hall.html +117 -18
  10. package/bin/navegador.mjs +170 -1
  11. package/bin/serve.py +201 -101
  12. package/bin/test +15 -3
  13. package/bin/test-arnes.py +192 -0
  14. package/bin/test-cage.py +9 -0
  15. package/bin/test-channel.py +178 -12
  16. package/bin/test-claude-runtime.py +132 -4
  17. package/bin/test-connect-client.mjs +609 -0
  18. package/bin/test-connect.py +52 -0
  19. package/bin/test-contracts.py +214 -0
  20. package/bin/test-diario.py +185 -0
  21. package/bin/test-doctor.py +31 -0
  22. package/bin/test-i18n.py +189 -0
  23. package/bin/test-navegador.py +61 -0
  24. package/bin/test-seat.py +78 -20
  25. package/bin/test-security.py +2 -0
  26. package/bin/test-serve.py +366 -0
  27. package/city/web/dist-hall/hall.js +789 -214
  28. package/city/web/src/bienvenida.ts +14 -13
  29. package/city/web/src/casa.ts +143 -56
  30. package/city/web/src/demo.ts +61 -31
  31. package/city/web/src/es.ts +187 -27
  32. package/city/web/src/explorador.ts +70 -43
  33. package/city/web/src/hall.ts +520 -103
  34. package/city/web/src/idioma.ts +21 -1
  35. package/city/web/src/motores.ts +48 -1
  36. package/city/web/src/vista.ts +58 -0
  37. package/demo/graba.py +5 -10
  38. package/docs/managed-connect.md +237 -0
  39. package/docs/security.md +41 -2
  40. package/package.json +8 -4
  41. package/plugin/.claude-plugin/plugin.json +1 -1
  42. package/plugin/channel/adapter-prompts.ts +10 -0
  43. package/plugin/channel/adapter.js +78 -31
  44. package/plugin/channel/agents_city_hybrid_crypto_bg.wasm +0 -0
  45. package/plugin/channel/bus.js +109 -65
  46. package/plugin/channel/bus.ts +11 -1
  47. package/plugin/channel/client.js +67 -30
  48. package/plugin/channel/delivery-queue.ts +160 -21
  49. package/plugin/channel/hub/remote-roads.ts +32 -1
  50. package/plugin/channel/hub/road-controller.ts +116 -16
  51. package/plugin/channel/kinsh_vodozemac_wasm_bg.wasm +0 -0
  52. package/plugin/channel/licenses/CONNECT_CLIENT_THIRD_PARTY_NOTICES.md +21 -0
  53. package/plugin/channel/licenses/HYBRID_CRYPTO_THIRD_PARTY_NOTICES.md +12 -0
  54. package/plugin/channel/licenses/hybrid-crypto-Apache-2.0.txt +201 -0
  55. package/plugin/channel/licenses/keyring-MIT.txt +21 -0
  56. package/plugin/channel/licenses/vodozemac-Apache-2.0.txt +201 -0
  57. package/plugin/channel/local-hub.js +2080 -97
  58. package/plugin/channel/local-hub.ts +5 -4
  59. package/plugin/channel/managed-connect/bridge.ts +208 -0
  60. package/plugin/channel/managed-connect/cli.ts +416 -0
  61. package/plugin/channel/managed-connect/device.ts +15 -0
  62. package/plugin/channel/managed-connect/local-cities.ts +94 -0
  63. package/plugin/channel/managed-connect/person-message.ts +74 -0
  64. package/plugin/channel/managed-connect/reception-bridge.ts +341 -0
  65. package/plugin/channel/managed-connect/relay-session.ts +7 -0
  66. package/plugin/channel/managed-connect/storage.ts +653 -0
  67. package/plugin/channel/managed-connect/transport.ts +87 -0
  68. package/plugin/channel/managed-connect-cli.js +4777 -0
  69. package/plugin/channel/managed-connect-cli.ts +7 -0
  70. package/plugin/channel/managed-connect-client.d.ts +261 -0
  71. package/plugin/channel/managed-connect-client.js +6572 -0
  72. package/plugin/channel/managed-connect-client.manifest.json +38 -0
  73. package/plugin/channel/package-lock.json +123 -105
  74. package/plugin/channel/package.json +4 -4
  75. package/plugin/channel/protocol.ts +4 -0
  76. package/plugin/channel/reception.ts +896 -0
  77. package/plugin/channel/road-cli.ts +1 -1
  78. package/plugin/channel/runtime/arnes.json +189 -0
  79. package/plugin/channel/runtime/arnes.ts +47 -0
  80. package/plugin/channel/runtime/claude.ts +18 -0
  81. package/plugin/channel/runtime/codex-config.ts +51 -1
  82. package/plugin/channel/runtime/codex.ts +31 -11
  83. package/plugin/channel/runtime/kimi.ts +6 -4
  84. package/plugin/channel/runtime-files.ts +39 -5
  85. package/plugin/channel/runtime-gateway.js +426 -100
  86. package/plugin/channel/runtime-gateway.ts +12 -0
  87. package/plugin/channel/trust/agents-city-sandbox-roots.json +66 -0
  88. package/plugin/scripts/arnes.py +271 -0
  89. package/plugin/scripts/busca.py +46 -10
  90. package/plugin/scripts/cage.py +5 -0
  91. package/plugin/scripts/city-session.sh +144 -28
  92. package/plugin/scripts/crecimiento.py +4 -1
  93. package/plugin/scripts/demos.py +58 -43
  94. package/plugin/scripts/desinstala.py +36 -21
  95. package/plugin/scripts/diario.py +119 -0
  96. package/plugin/scripts/doctor.py +90 -15
  97. package/plugin/scripts/read-card.py +48 -8
  98. package/plugin/scripts/reception.py +665 -0
@@ -0,0 +1,896 @@
1
+ /**
2
+ * The owner-level reception boundary for messages that arrive from another
3
+ * machine.
4
+ *
5
+ * Remote ciphertext is decrypted by the local Connect client, then lands in
6
+ * this SQLite quarantine. It does not enter a city runtime, wake a model, or
7
+ * become available through `road.inbox` until the owner routes it in the Hall.
8
+ * Several city processes and the Python Hall may share the database, hence WAL,
9
+ * an immediate transaction for capacity checks, and conditional route claims.
10
+ */
11
+
12
+ import { createHash } from 'node:crypto';
13
+ import { chmodSync, existsSync, lstatSync, mkdirSync, realpathSync } from 'node:fs';
14
+ import { join, resolve } from 'node:path';
15
+ import { DatabaseSync } from 'node:sqlite';
16
+ import type { CityContext } from './city-config.js';
17
+ import { markRoadInboxAccepted, recordRoadInbox } from './delivery-queue.js';
18
+ import { BUS_PROTOCOL, MAX_BODY, type BusEnvelope } from './protocol.js';
19
+ import { wrapUntrusted } from './untrusted.js';
20
+
21
+ export const RECEPTION_PROTOCOL = 'agents-city-reception/1' as const;
22
+ export const RECEPTION_SCHEMA_VERSION = 3;
23
+ export const AUTO_ROUTER_PROFILE = 'deterministic-rules/1' as const;
24
+ const DEFAULT_PENDING_MESSAGES = 10_000;
25
+ const MAX_PENDING_MESSAGES = 100_000;
26
+ const DEFAULT_PENDING_BYTES = 64 * 1024 * 1024;
27
+ const MAX_PENDING_BYTES = 512 * 1024 * 1024;
28
+ const DELIVERY_BATCH = 20;
29
+
30
+ type ReceptionDatabase = DatabaseSync;
31
+
32
+ type ReceptionContext = Pick<CityContext, 'appHome'> & {
33
+ city: Pick<CityContext['city'], 'id' | 'address'>;
34
+ };
35
+
36
+ export type ReceptionConnectionInput = {
37
+ roadId: string;
38
+ connectionId: string;
39
+ peerName: string;
40
+ peerEndpoint: string;
41
+ };
42
+
43
+ export type ReceptionOutboxRecord = {
44
+ messageId: string;
45
+ roadId: string;
46
+ connectionId: string;
47
+ kind: 'message' | 'rejection';
48
+ body: string;
49
+ inReplyTo: string | null;
50
+ attemptCount: number;
51
+ };
52
+
53
+ type PendingRoute = {
54
+ message_id: string;
55
+ source_city: string;
56
+ source_created_at: string;
57
+ body: string;
58
+ connection_id: string | null;
59
+ road_id: string | null;
60
+ approved_at: string;
61
+ approved_by: 'human' | 'auto';
62
+ attempt_count: number;
63
+ };
64
+
65
+ export interface ReceptionRecordResult {
66
+ inserted: boolean;
67
+ pending: number;
68
+ pendingBytes: number;
69
+ }
70
+
71
+ export interface ReceptionDeliveryResult {
72
+ delivered: number;
73
+ failed: number;
74
+ remaining: number;
75
+ }
76
+
77
+ const databases = new Map<string, ReceptionDatabase>();
78
+
79
+ export function receptionDatabasePath(appHome: string): string {
80
+ return join(resolve(appHome), '.runtime', 'reception', 'reception.sqlite3');
81
+ }
82
+
83
+ /**
84
+ * Persist one decrypted remote message at the human boundary. Duplicate relay
85
+ * delivery is idempotent by message id. The function returns only after SQLite
86
+ * has committed, so the Connect session may safely ACK the relay afterwards.
87
+ */
88
+ export function recordReceptionMessage(
89
+ context: ReceptionContext,
90
+ envelope: BusEnvelope,
91
+ ): ReceptionRecordResult {
92
+ return recordReceptionMessages(context, [envelope]);
93
+ }
94
+
95
+ /** Commit one relay frame in one transaction (up to protocol v4's 32 items). */
96
+ export function recordReceptionMessages(
97
+ context: ReceptionContext,
98
+ envelopes: BusEnvelope[],
99
+ ): ReceptionRecordResult {
100
+ if (!envelopes.length || envelopes.length > 32) {
101
+ throw new Error('invalid_reception_message_batch');
102
+ }
103
+ const rows = envelopes.map(validateReceptionEnvelope);
104
+ if (new Set(rows.map((row) => row.envelope.id)).size !== rows.length) {
105
+ throw new Error('duplicate_reception_message_batch');
106
+ }
107
+ const database = receptionDatabase(context.appHome);
108
+ const maximumMessages = boundedInteger(
109
+ process.env.CITY_RECEPTION_MAX_PENDING,
110
+ DEFAULT_PENDING_MESSAGES,
111
+ 100,
112
+ MAX_PENDING_MESSAGES,
113
+ );
114
+ const maximumBytes = boundedInteger(
115
+ process.env.CITY_RECEPTION_MAX_BYTES,
116
+ DEFAULT_PENDING_BYTES,
117
+ 1024 * 1024,
118
+ MAX_PENDING_BYTES,
119
+ );
120
+ let committed = false;
121
+ try {
122
+ database.exec('BEGIN IMMEDIATE');
123
+ expireOldReception(database);
124
+ const fresh = rows.filter((row) => {
125
+ const existing = database
126
+ .prepare('SELECT 1 AS found FROM reception_messages WHERE message_id = ? LIMIT 1')
127
+ .get(row.envelope.id) as { found: number } | undefined;
128
+ return !existing;
129
+ });
130
+ if (!fresh.length) {
131
+ const current = receptionCounters(database);
132
+ database.exec('COMMIT');
133
+ committed = true;
134
+ return { inserted: false, ...current };
135
+ }
136
+ const counters = receptionCounters(database);
137
+ const bytes = fresh.reduce((sum, row) => sum + row.bytes, 0);
138
+ if (
139
+ counters.pending + fresh.length > maximumMessages ||
140
+ counters.pendingBytes + bytes > maximumBytes
141
+ ) {
142
+ throw new Error('reception_inbox_full');
143
+ }
144
+ const insert = database.prepare(`
145
+ INSERT INTO reception_messages (
146
+ message_id, protocol, state, source_city, source_created_at,
147
+ source_name, message_kind, in_reply_to,
148
+ received_city_id, received_city_address, body, body_sha256,
149
+ connection_id, road_id, remote_message_id, received_at
150
+ ) VALUES (?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
151
+ `);
152
+ const receivedAt = new Date().toISOString();
153
+ for (const row of fresh) {
154
+ const envelope = row.envelope;
155
+ insert.run(
156
+ envelope.id,
157
+ RECEPTION_PROTOCOL,
158
+ envelope.from.city,
159
+ envelope.createdAt,
160
+ row.sourceName,
161
+ row.kind,
162
+ row.inReplyTo,
163
+ context.city.id,
164
+ context.city.address,
165
+ row.body,
166
+ sha256(row.body),
167
+ optionalText(envelope.payload?.connectionId, 160),
168
+ optionalText(envelope.payload?.roadId, 160),
169
+ optionalText(envelope.payload?.remoteMessageId, 160),
170
+ receivedAt,
171
+ );
172
+ }
173
+ applyAutomaticRouting(
174
+ database,
175
+ fresh.map((row) => row.envelope.id),
176
+ );
177
+ const updated = receptionCounters(database);
178
+ database.exec('COMMIT');
179
+ committed = true;
180
+ return { inserted: true, ...updated };
181
+ } finally {
182
+ if (!committed) {
183
+ try {
184
+ database.exec('ROLLBACK');
185
+ } catch {}
186
+ }
187
+ }
188
+ }
189
+
190
+ function validateReceptionEnvelope(envelope: BusEnvelope) {
191
+ const body = envelope.payload?.text;
192
+ if (typeof body !== 'string' || !body || body.length > MAX_BODY) {
193
+ throw new Error('invalid_reception_message_body');
194
+ }
195
+ if (envelope.payload?.transport !== 'managed-e2ee') {
196
+ throw new Error('reception_accepts_managed_messages_only');
197
+ }
198
+ const kind = envelope.payload?.messageKind === 'rejection' ? 'rejection' : 'message';
199
+ const sourceName = optionalText(envelope.payload?.sourceName, 100) ?? envelope.from.city;
200
+ const inReplyTo = optionalText(envelope.payload?.inReplyTo, 180);
201
+ return { envelope, body, kind, sourceName, inReplyTo, bytes: Buffer.byteLength(body, 'utf8') };
202
+ }
203
+
204
+ type AutomaticRule = {
205
+ ruleId: string;
206
+ targetCityId: string;
207
+ targetCityAddress: string;
208
+ keywords: string[];
209
+ priority: number;
210
+ };
211
+
212
+ /**
213
+ * A capability-free classifier: deterministic owner rules in, one allowlisted
214
+ * destination out. It never interprets the text as instructions and cannot
215
+ * answer, fetch, execute, discover cities or access a model/tool credential.
216
+ */
217
+ function applyAutomaticRouting(database: ReceptionDatabase, messageIds: string[]): void {
218
+ if (!messageIds.length) return;
219
+ const settings = database
220
+ .prepare(
221
+ `
222
+ SELECT routing_mode, router_profile FROM reception_settings WHERE singleton = 1
223
+ `,
224
+ )
225
+ .get() as { routing_mode?: string; router_profile?: string | null } | undefined;
226
+ if (settings?.routing_mode !== 'auto' || settings.router_profile !== AUTO_ROUTER_PROFILE) return;
227
+ const rawRules = database
228
+ .prepare(
229
+ `
230
+ SELECT rule_id, target_city_id, target_city_address, keywords_json, priority
231
+ FROM reception_auto_rules WHERE enabled = 1
232
+ ORDER BY priority DESC, rule_id
233
+ `,
234
+ )
235
+ .all() as unknown as Array<Record<string, unknown>>;
236
+ const rules = rawRules
237
+ .map(parseAutomaticRule)
238
+ .filter((rule): rule is AutomaticRule => Boolean(rule));
239
+ if (!rules.length) return;
240
+ const route = database.prepare(`
241
+ INSERT INTO reception_routes (
242
+ message_id, target_city_id, target_city_address, state, approved_by, approved_at
243
+ ) VALUES (?, ?, ?, 'queued', 'auto', ?)
244
+ `);
245
+ const decide = database.prepare(`
246
+ UPDATE reception_messages
247
+ SET state = 'routed', decided_at = ?, decision_reason = ?
248
+ WHERE message_id = ? AND state = 'pending' AND message_kind = 'message'
249
+ `);
250
+ for (const messageId of messageIds) {
251
+ const message = database
252
+ .prepare(
253
+ `
254
+ SELECT body, message_kind FROM reception_messages
255
+ WHERE message_id = ? AND state = 'pending' LIMIT 1
256
+ `,
257
+ )
258
+ .get(messageId) as { body?: string | null; message_kind?: string } | undefined;
259
+ if (message?.message_kind !== 'message' || typeof message.body !== 'string') continue;
260
+ const selected = automaticDestination(message.body, rules);
261
+ if (!selected) continue;
262
+ const now = new Date().toISOString();
263
+ route.run(messageId, selected.targetCityId, selected.targetCityAddress, now);
264
+ const result = decide.run(now, `auto:${selected.ruleId}`, messageId);
265
+ if (Number(result.changes) !== 1) throw new Error('automatic_reception_decision_conflict');
266
+ }
267
+ }
268
+
269
+ function parseAutomaticRule(value: Record<string, unknown>): AutomaticRule | null {
270
+ let keywords: unknown;
271
+ try {
272
+ keywords = JSON.parse(String(value.keywords_json ?? ''));
273
+ } catch {
274
+ return null;
275
+ }
276
+ if (
277
+ typeof value.rule_id !== 'string' ||
278
+ typeof value.target_city_id !== 'string' ||
279
+ typeof value.target_city_address !== 'string' ||
280
+ !Array.isArray(keywords) ||
281
+ keywords.length < 1 ||
282
+ keywords.length > 20 ||
283
+ !keywords.every((keyword) => typeof keyword === 'string' && keyword.length <= 80)
284
+ )
285
+ return null;
286
+ return {
287
+ ruleId: value.rule_id,
288
+ targetCityId: value.target_city_id,
289
+ targetCityAddress: value.target_city_address,
290
+ keywords: keywords.map((keyword) => normalizeForRouting(keyword as string)).filter(Boolean),
291
+ priority: Number(value.priority) || 0,
292
+ };
293
+ }
294
+
295
+ function automaticDestination(body: string, rules: AutomaticRule[]): AutomaticRule | null {
296
+ const normalized = normalizeForRouting(body);
297
+ if (!normalized || riskyAutomaticText(normalized)) return null;
298
+ const matches = rules
299
+ .map((rule) => ({
300
+ rule,
301
+ score: rule.keywords.reduce(
302
+ (score, keyword) => score + (keyword === '*' || normalized.includes(keyword) ? 1 : 0),
303
+ 0,
304
+ ),
305
+ }))
306
+ .filter((candidate) => candidate.score > 0);
307
+ if (!matches.length) return null;
308
+ matches.sort(
309
+ (left, right) =>
310
+ right.score - left.score ||
311
+ right.rule.priority - left.rule.priority ||
312
+ left.rule.ruleId.localeCompare(right.rule.ruleId),
313
+ );
314
+ const winner = matches[0]!;
315
+ const runnerUp = matches[1];
316
+ if (
317
+ runnerUp &&
318
+ runnerUp.score === winner.score &&
319
+ runnerUp.rule.priority === winner.rule.priority
320
+ )
321
+ return null;
322
+ return winner.rule;
323
+ }
324
+
325
+ function normalizeForRouting(value: string): string {
326
+ return value.normalize('NFKC').toLocaleLowerCase('en').replace(/\s+/g, ' ').trim();
327
+ }
328
+
329
+ function riskyAutomaticText(value: string): boolean {
330
+ return [
331
+ /<\|(?:im_start|im_end|system|developer)\|>/i,
332
+ /\[(?:system|inst|\/inst)\]/i,
333
+ /\b(?:ignore|disregard|override)\b.{0,40}\b(?:previous|system|developer|instructions?)\b/i,
334
+ /\b(?:system|developer)\s+(?:prompt|message)\b/i,
335
+ /\b(?:reveal|print|send|exfiltrate)\b.{0,50}\b(?:password|secret|api[ -]?key|token|credential)\b/i,
336
+ /\b(?:run|execute)\b.{0,30}\b(?:shell|command|terminal|tool)\b/i,
337
+ ].some((pattern) => pattern.test(value));
338
+ }
339
+
340
+ /** Replace the trusted local connection directory with the latest signed API view. */
341
+ export function syncReceptionConnections(
342
+ appHome: string,
343
+ connections: ReceptionConnectionInput[],
344
+ ): void {
345
+ const database = receptionDatabase(appHome);
346
+ let committed = false;
347
+ try {
348
+ database.exec('BEGIN IMMEDIATE');
349
+ database.prepare("UPDATE reception_connections SET status = 'inactive'").run();
350
+ const upsert = database.prepare(`
351
+ INSERT INTO reception_connections (
352
+ road_id, connection_id, peer_name, peer_endpoint, status, updated_at
353
+ ) VALUES (?, ?, ?, ?, 'active', ?)
354
+ ON CONFLICT(road_id) DO UPDATE SET
355
+ connection_id = excluded.connection_id,
356
+ peer_name = excluded.peer_name,
357
+ peer_endpoint = excluded.peer_endpoint,
358
+ status = 'active',
359
+ updated_at = excluded.updated_at
360
+ `);
361
+ const now = new Date().toISOString();
362
+ for (const connection of connections) {
363
+ if (
364
+ !connection.roadId ||
365
+ !connection.connectionId ||
366
+ !connection.peerName.trim() ||
367
+ connection.peerName.length > 100 ||
368
+ !connection.peerEndpoint
369
+ )
370
+ throw new Error('invalid_reception_connection');
371
+ upsert.run(
372
+ connection.roadId,
373
+ connection.connectionId,
374
+ connection.peerName.trim(),
375
+ connection.peerEndpoint,
376
+ now,
377
+ );
378
+ }
379
+ database.exec('COMMIT');
380
+ committed = true;
381
+ } finally {
382
+ if (!committed) {
383
+ try {
384
+ database.exec('ROLLBACK');
385
+ } catch {}
386
+ }
387
+ }
388
+ }
389
+
390
+ export function pendingReceptionOutbox(appHome: string, limit = 20): ReceptionOutboxRecord[] {
391
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 20) {
392
+ throw new Error('invalid_reception_outbox_batch');
393
+ }
394
+ return receptionDatabase(appHome)
395
+ .prepare(
396
+ `
397
+ SELECT o.message_id, o.road_id, o.connection_id, o.kind, o.body,
398
+ o.in_reply_to, o.attempt_count
399
+ FROM reception_outbox o
400
+ JOIN reception_connections c ON c.road_id = o.road_id
401
+ AND c.connection_id = o.connection_id
402
+ WHERE o.state = 'queued' AND o.body IS NOT NULL AND c.status = 'active'
403
+ AND (o.next_attempt_at IS NULL OR o.next_attempt_at <= ?)
404
+ ORDER BY o.created_at, o.message_id
405
+ LIMIT ?
406
+ `,
407
+ )
408
+ .all(Date.now(), limit)
409
+ .map((row) => {
410
+ const value = row as Record<string, unknown>;
411
+ return {
412
+ messageId: String(value.message_id),
413
+ roadId: String(value.road_id),
414
+ connectionId: String(value.connection_id),
415
+ kind: value.kind === 'rejection' ? 'rejection' : 'message',
416
+ body: String(value.body),
417
+ inReplyTo: typeof value.in_reply_to === 'string' ? value.in_reply_to : null,
418
+ attemptCount: Number(value.attempt_count),
419
+ };
420
+ });
421
+ }
422
+
423
+ export function markReceptionOutboxSent(appHome: string, messageId: string): void {
424
+ receptionDatabase(appHome)
425
+ .prepare(
426
+ `
427
+ UPDATE reception_outbox
428
+ SET state = 'sent', body = NULL, sent_at = ?, next_attempt_at = NULL, error = NULL
429
+ WHERE message_id = ? AND state = 'queued'
430
+ `,
431
+ )
432
+ .run(new Date().toISOString(), messageId);
433
+ }
434
+
435
+ export function markReceptionOutboxFailed(
436
+ appHome: string,
437
+ messageId: string,
438
+ attemptCount: number,
439
+ error: unknown,
440
+ ): void {
441
+ const attempts = Math.max(1, attemptCount + 1);
442
+ const retryAt = Date.now() + Math.min(300_000, 1_000 * 2 ** Math.min(attempts - 1, 8));
443
+ receptionDatabase(appHome)
444
+ .prepare(
445
+ `
446
+ UPDATE reception_outbox
447
+ SET attempt_count = ?, last_attempt_at = ?, next_attempt_at = ?, error = ?
448
+ WHERE message_id = ? AND state = 'queued'
449
+ `,
450
+ )
451
+ .run(
452
+ attempts,
453
+ Date.now(),
454
+ retryAt,
455
+ String(error instanceof Error ? error.message : error).slice(0, 300),
456
+ messageId,
457
+ );
458
+ }
459
+
460
+ /**
461
+ * Move human-approved messages into this city's ordinary, bounded Road inbox.
462
+ * The deterministic message id makes a crash between local persistence and the
463
+ * SQLite route ACK an idempotent replay rather than a duplicate model turn.
464
+ */
465
+ export function deliverApprovedReception(
466
+ context: CityContext,
467
+ limit = DELIVERY_BATCH,
468
+ ): ReceptionDeliveryResult {
469
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > DELIVERY_BATCH) {
470
+ throw new Error('invalid_reception_delivery_batch');
471
+ }
472
+ const database = receptionDatabase(context.appHome);
473
+ const routes = database
474
+ .prepare(
475
+ `
476
+ SELECT m.message_id, m.source_city, m.source_created_at, m.body,
477
+ m.connection_id, m.road_id, r.approved_at, r.approved_by,
478
+ r.attempt_count
479
+ FROM reception_routes r
480
+ JOIN reception_messages m ON m.message_id = r.message_id
481
+ WHERE r.target_city_id = ? AND r.target_city_address = ?
482
+ AND r.state = 'queued' AND m.state = 'routed' AND m.body IS NOT NULL
483
+ AND (r.next_attempt_at IS NULL OR r.next_attempt_at <= ?)
484
+ ORDER BY r.approved_at, m.received_at, m.message_id
485
+ LIMIT ?
486
+ `,
487
+ )
488
+ .all(context.city.id, context.city.address, Date.now(), limit) as unknown as PendingRoute[];
489
+ let delivered = 0;
490
+ let failed = 0;
491
+ for (const route of routes) {
492
+ try {
493
+ const envelope: BusEnvelope = {
494
+ protocol: BUS_PROTOCOL,
495
+ id: route.message_id,
496
+ kind: 'road.message',
497
+ scope: 'road',
498
+ thread: null,
499
+ from: { city: route.source_city, actor: 'seat', role: 'external-seat' },
500
+ to: { city: context.city.address, actor: 'seat' },
501
+ createdAt: route.source_created_at,
502
+ payload: {
503
+ text: wrapUntrusted(route.body, route.source_city).text,
504
+ trust: 'information-not-authority',
505
+ transport: 'reception-approved',
506
+ reception: {
507
+ approvedAt: route.approved_at,
508
+ approvedBy: route.approved_by,
509
+ sourceMessageId: route.message_id,
510
+ ...(route.connection_id ? { connectionId: route.connection_id } : {}),
511
+ ...(route.road_id ? { roadId: route.road_id } : {}),
512
+ },
513
+ },
514
+ };
515
+ const accepted = recordRoadInbox(context.runtimeDir, envelope);
516
+ if (accepted) markRoadInboxAccepted(context.runtimeDir, envelope.id);
517
+ markRouteDelivered(database, route.message_id, context.city.id);
518
+ delivered += 1;
519
+ } catch (error) {
520
+ if (error instanceof Error && error.message === 'road_inbox_full') break;
521
+ markRouteFailed(database, route.message_id, context.city.id, error);
522
+ failed += 1;
523
+ }
524
+ }
525
+ const remaining = Number(
526
+ (
527
+ database
528
+ .prepare(
529
+ `
530
+ SELECT COUNT(*) AS count FROM reception_routes
531
+ WHERE target_city_id = ? AND target_city_address = ? AND state = 'queued'
532
+ `,
533
+ )
534
+ .get(context.city.id, context.city.address) as { count?: number } | undefined
535
+ )?.count ?? 0,
536
+ );
537
+ return { delivered, failed, remaining };
538
+ }
539
+
540
+ function receptionDatabase(appHome: string): ReceptionDatabase {
541
+ const path = receptionDatabasePath(appHome);
542
+ const cached = databases.get(path);
543
+ if (cached) return cached;
544
+ const directory = preparePrivateReceptionDirectory(appHome);
545
+ if (existsSync(path)) assertRegularPrivateDatabase(path);
546
+ const database = new DatabaseSync(path);
547
+ chmodSync(path, 0o600);
548
+ database.exec('PRAGMA journal_mode = WAL');
549
+ database.exec('PRAGMA synchronous = FULL');
550
+ database.exec('PRAGMA foreign_keys = ON');
551
+ database.exec('PRAGMA busy_timeout = 5000');
552
+ initializeSchema(database);
553
+ chmodSync(directory, 0o700);
554
+ databases.set(path, database);
555
+ return database;
556
+ }
557
+
558
+ function preparePrivateReceptionDirectory(appHome: string): string {
559
+ const home = realpathSync(resolve(appHome));
560
+ const runtime = join(home, '.runtime');
561
+ const reception = join(runtime, 'reception');
562
+ for (const path of [runtime, reception]) {
563
+ if (!existsSync(path)) mkdirSync(path, { mode: 0o700 });
564
+ const info = lstatSync(path);
565
+ if (!info.isDirectory() || info.isSymbolicLink()) {
566
+ throw new Error(`unsafe_reception_directory:${path}`);
567
+ }
568
+ chmodSync(path, 0o700);
569
+ }
570
+ return reception;
571
+ }
572
+
573
+ function assertRegularPrivateDatabase(path: string): void {
574
+ const info = lstatSync(path);
575
+ if (!info.isFile() || info.isSymbolicLink()) throw new Error('unsafe_reception_database');
576
+ chmodSync(path, 0o600);
577
+ }
578
+
579
+ function initializeSchema(database: ReceptionDatabase): void {
580
+ database.exec(`
581
+ CREATE TABLE IF NOT EXISTS reception_meta (
582
+ singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
583
+ schema_version INTEGER NOT NULL,
584
+ created_at TEXT NOT NULL
585
+ );
586
+ CREATE TABLE IF NOT EXISTS reception_settings (
587
+ singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
588
+ routing_mode TEXT NOT NULL DEFAULT 'manual' CHECK (routing_mode IN ('manual', 'auto')),
589
+ review_policy TEXT NOT NULL DEFAULT 'every_message' CHECK (review_policy IN ('every_message', 'new_thread')),
590
+ router_profile TEXT CHECK (router_profile IS NULL OR length(router_profile) <= 160),
591
+ updated_at TEXT NOT NULL,
592
+ CHECK (routing_mode = 'manual' OR router_profile IS NOT NULL)
593
+ );
594
+ CREATE TABLE IF NOT EXISTS reception_messages (
595
+ message_id TEXT PRIMARY KEY CHECK (length(message_id) BETWEEN 1 AND 180),
596
+ protocol TEXT NOT NULL CHECK (protocol = '${RECEPTION_PROTOCOL}'),
597
+ state TEXT NOT NULL CHECK (state IN ('pending', 'routed', 'rejected', 'expired')),
598
+ source_city TEXT NOT NULL CHECK (length(source_city) BETWEEN 3 AND 160),
599
+ source_created_at TEXT NOT NULL,
600
+ source_name TEXT CHECK (source_name IS NULL OR length(source_name) <= 100),
601
+ message_kind TEXT NOT NULL DEFAULT 'message' CHECK (message_kind IN ('message', 'rejection')),
602
+ in_reply_to TEXT CHECK (in_reply_to IS NULL OR length(in_reply_to) <= 180),
603
+ received_city_id TEXT NOT NULL CHECK (length(received_city_id) BETWEEN 1 AND 160),
604
+ received_city_address TEXT NOT NULL CHECK (length(received_city_address) BETWEEN 3 AND 160),
605
+ body TEXT,
606
+ body_sha256 TEXT NOT NULL CHECK (length(body_sha256) = 64),
607
+ connection_id TEXT,
608
+ road_id TEXT,
609
+ remote_message_id TEXT,
610
+ received_at TEXT NOT NULL,
611
+ decided_at TEXT,
612
+ decision_reason TEXT CHECK (decision_reason IS NULL OR length(decision_reason) <= 500),
613
+ CHECK (state = 'pending' OR decided_at IS NOT NULL),
614
+ CHECK (state <> 'pending' OR body IS NOT NULL)
615
+ );
616
+ CREATE INDEX IF NOT EXISTS idx_reception_messages_state_age
617
+ ON reception_messages (state, received_at, message_id);
618
+ CREATE TABLE IF NOT EXISTS reception_routes (
619
+ message_id TEXT NOT NULL REFERENCES reception_messages(message_id) ON DELETE CASCADE,
620
+ target_city_id TEXT NOT NULL CHECK (length(target_city_id) BETWEEN 1 AND 160),
621
+ target_city_address TEXT NOT NULL CHECK (length(target_city_address) BETWEEN 3 AND 160),
622
+ state TEXT NOT NULL DEFAULT 'queued' CHECK (state IN ('queued', 'delivered', 'failed')),
623
+ approved_by TEXT NOT NULL CHECK (approved_by IN ('human', 'auto')),
624
+ approved_at TEXT NOT NULL,
625
+ delivered_at TEXT,
626
+ error TEXT CHECK (error IS NULL OR length(error) <= 300),
627
+ attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count >= 0),
628
+ last_attempt_at INTEGER,
629
+ next_attempt_at INTEGER,
630
+ PRIMARY KEY (message_id, target_city_id)
631
+ );
632
+ CREATE INDEX IF NOT EXISTS idx_reception_routes_city_state
633
+ ON reception_routes (target_city_id, state, approved_at);
634
+ CREATE TABLE IF NOT EXISTS reception_counters (
635
+ singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
636
+ pending_count INTEGER NOT NULL DEFAULT 0 CHECK (pending_count >= 0),
637
+ pending_bytes INTEGER NOT NULL DEFAULT 0 CHECK (pending_bytes >= 0)
638
+ );
639
+ CREATE TABLE IF NOT EXISTS reception_connections (
640
+ road_id TEXT PRIMARY KEY,
641
+ connection_id TEXT NOT NULL UNIQUE,
642
+ peer_name TEXT NOT NULL CHECK (length(peer_name) BETWEEN 1 AND 100),
643
+ peer_endpoint TEXT NOT NULL CHECK (length(peer_endpoint) BETWEEN 3 AND 160),
644
+ status TEXT NOT NULL CHECK (status IN ('active', 'inactive')),
645
+ updated_at TEXT NOT NULL
646
+ );
647
+ CREATE TABLE IF NOT EXISTS reception_outbox (
648
+ message_id TEXT PRIMARY KEY CHECK (length(message_id) = 36),
649
+ road_id TEXT NOT NULL,
650
+ connection_id TEXT NOT NULL,
651
+ kind TEXT NOT NULL CHECK (kind IN ('message', 'rejection')),
652
+ body TEXT,
653
+ in_reply_to TEXT CHECK (in_reply_to IS NULL OR length(in_reply_to) <= 180),
654
+ state TEXT NOT NULL DEFAULT 'queued' CHECK (state IN ('queued', 'sent')),
655
+ created_at TEXT NOT NULL,
656
+ sent_at TEXT,
657
+ attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count >= 0),
658
+ last_attempt_at INTEGER,
659
+ next_attempt_at INTEGER,
660
+ error TEXT CHECK (error IS NULL OR length(error) <= 300)
661
+ );
662
+ CREATE INDEX IF NOT EXISTS idx_reception_outbox_state_age
663
+ ON reception_outbox (state, next_attempt_at, created_at);
664
+ CREATE TABLE IF NOT EXISTS reception_auto_rules (
665
+ rule_id TEXT PRIMARY KEY CHECK (length(rule_id) = 36),
666
+ target_city_id TEXT NOT NULL UNIQUE CHECK (length(target_city_id) BETWEEN 1 AND 160),
667
+ target_city_address TEXT NOT NULL CHECK (length(target_city_address) BETWEEN 3 AND 160),
668
+ keywords_json TEXT NOT NULL CHECK (length(keywords_json) BETWEEN 5 AND 2000),
669
+ priority INTEGER NOT NULL DEFAULT 0 CHECK (priority BETWEEN 0 AND 1000),
670
+ enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
671
+ updated_at TEXT NOT NULL
672
+ );
673
+ CREATE TRIGGER IF NOT EXISTS reception_message_count_after_insert
674
+ AFTER INSERT ON reception_messages WHEN NEW.state = 'pending'
675
+ BEGIN
676
+ UPDATE reception_counters
677
+ SET pending_count = pending_count + 1,
678
+ pending_bytes = pending_bytes + length(CAST(NEW.body AS BLOB))
679
+ WHERE singleton = 1;
680
+ END;
681
+ CREATE TRIGGER IF NOT EXISTS reception_message_count_after_decision
682
+ AFTER UPDATE OF state ON reception_messages
683
+ WHEN OLD.state = 'pending' AND NEW.state <> 'pending'
684
+ BEGIN
685
+ UPDATE reception_counters
686
+ SET pending_count = MAX(0, pending_count - 1),
687
+ pending_bytes = MAX(0, pending_bytes - length(CAST(OLD.body AS BLOB)))
688
+ WHERE singleton = 1;
689
+ END;
690
+ CREATE TRIGGER IF NOT EXISTS reception_message_count_after_delete
691
+ AFTER DELETE ON reception_messages WHEN OLD.state = 'pending'
692
+ BEGIN
693
+ UPDATE reception_counters
694
+ SET pending_count = MAX(0, pending_count - 1),
695
+ pending_bytes = MAX(0, pending_bytes - length(CAST(OLD.body AS BLOB)))
696
+ WHERE singleton = 1;
697
+ END;
698
+ `);
699
+ const meta = database
700
+ .prepare('SELECT schema_version FROM reception_meta WHERE singleton = 1')
701
+ .get() as { schema_version: number } | undefined;
702
+ if (meta?.schema_version === 1) {
703
+ database.exec('BEGIN IMMEDIATE');
704
+ try {
705
+ const current = database
706
+ .prepare('SELECT schema_version FROM reception_meta WHERE singleton = 1')
707
+ .get() as { schema_version: number } | undefined;
708
+ if (current?.schema_version === 1) {
709
+ database.exec(`
710
+ ALTER TABLE reception_messages ADD COLUMN source_name TEXT
711
+ CHECK (source_name IS NULL OR length(source_name) <= 100);
712
+ ALTER TABLE reception_messages ADD COLUMN message_kind TEXT NOT NULL DEFAULT 'message'
713
+ CHECK (message_kind IN ('message', 'rejection'));
714
+ ALTER TABLE reception_messages ADD COLUMN in_reply_to TEXT
715
+ CHECK (in_reply_to IS NULL OR length(in_reply_to) <= 180);
716
+ UPDATE reception_meta SET schema_version = ${RECEPTION_SCHEMA_VERSION} WHERE singleton = 1;
717
+ `);
718
+ } else if (current?.schema_version === 2) {
719
+ database
720
+ .prepare('UPDATE reception_meta SET schema_version = ? WHERE singleton = 1')
721
+ .run(RECEPTION_SCHEMA_VERSION);
722
+ } else if (current?.schema_version !== RECEPTION_SCHEMA_VERSION) {
723
+ throw new Error(`unsupported_reception_schema:${current?.schema_version ?? 'missing'}`);
724
+ }
725
+ database.exec('COMMIT');
726
+ } catch (error) {
727
+ try {
728
+ database.exec('ROLLBACK');
729
+ } catch {}
730
+ throw error;
731
+ }
732
+ } else if (meta && meta.schema_version !== RECEPTION_SCHEMA_VERSION) {
733
+ throw new Error(`unsupported_reception_schema:${meta.schema_version}`);
734
+ }
735
+ database
736
+ .prepare(
737
+ `
738
+ INSERT OR IGNORE INTO reception_meta (singleton, schema_version, created_at)
739
+ VALUES (1, ?, ?)
740
+ `,
741
+ )
742
+ .run(RECEPTION_SCHEMA_VERSION, new Date().toISOString());
743
+ database
744
+ .prepare(
745
+ `
746
+ INSERT OR IGNORE INTO reception_settings (
747
+ singleton, routing_mode, review_policy, router_profile, updated_at
748
+ ) VALUES (1, 'manual', 'every_message', NULL, ?)
749
+ `,
750
+ )
751
+ .run(new Date().toISOString());
752
+ database
753
+ .prepare(
754
+ `
755
+ INSERT OR IGNORE INTO reception_counters (singleton, pending_count, pending_bytes)
756
+ VALUES (1, 0, 0)
757
+ `,
758
+ )
759
+ .run();
760
+ }
761
+
762
+ function receptionCounters(database: ReceptionDatabase) {
763
+ const row = database
764
+ .prepare('SELECT pending_count, pending_bytes FROM reception_counters WHERE singleton = 1')
765
+ .get() as { pending_count?: number; pending_bytes?: number } | undefined;
766
+ return {
767
+ pending: Number(row?.pending_count ?? 0),
768
+ pendingBytes: Number(row?.pending_bytes ?? 0),
769
+ };
770
+ }
771
+
772
+ function expireOldReception(database: ReceptionDatabase): void {
773
+ const retentionDays = boundedInteger(process.env.CITY_RECEPTION_PENDING_DAYS, 30, 1, 90);
774
+ database
775
+ .prepare(
776
+ `
777
+ UPDATE reception_messages
778
+ SET state = 'expired', body = NULL, decided_at = ?, decision_reason = 'expired locally'
779
+ WHERE state = 'pending' AND received_at < ?
780
+ `,
781
+ )
782
+ .run(
783
+ new Date().toISOString(),
784
+ new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1_000).toISOString(),
785
+ );
786
+ database
787
+ .prepare(
788
+ `
789
+ DELETE FROM reception_messages
790
+ WHERE state IN ('rejected', 'expired') AND decided_at < ?
791
+ `,
792
+ )
793
+ .run(new Date(Date.now() - 30 * 24 * 60 * 60 * 1_000).toISOString());
794
+ database
795
+ .prepare(
796
+ `
797
+ DELETE FROM reception_messages
798
+ WHERE state = 'routed' AND body IS NULL AND decided_at < ?
799
+ `,
800
+ )
801
+ .run(new Date(Date.now() - 30 * 24 * 60 * 60 * 1_000).toISOString());
802
+ }
803
+
804
+ function markRouteDelivered(database: ReceptionDatabase, messageId: string, cityId: string): void {
805
+ let committed = false;
806
+ try {
807
+ database.exec('BEGIN IMMEDIATE');
808
+ database
809
+ .prepare(
810
+ `
811
+ UPDATE reception_routes
812
+ SET state = 'delivered', delivered_at = ?, error = NULL,
813
+ next_attempt_at = NULL
814
+ WHERE message_id = ? AND target_city_id = ? AND state = 'queued'
815
+ `,
816
+ )
817
+ .run(new Date().toISOString(), messageId, cityId);
818
+ const waiting = Number(
819
+ (
820
+ database
821
+ .prepare(
822
+ `
823
+ SELECT COUNT(*) AS count FROM reception_routes
824
+ WHERE message_id = ? AND state <> 'delivered'
825
+ `,
826
+ )
827
+ .get(messageId) as { count?: number } | undefined
828
+ )?.count ?? 0,
829
+ );
830
+ if (!waiting) {
831
+ database
832
+ .prepare(
833
+ `
834
+ UPDATE reception_messages SET body = NULL
835
+ WHERE message_id = ? AND state = 'routed'
836
+ `,
837
+ )
838
+ .run(messageId);
839
+ }
840
+ database.exec('COMMIT');
841
+ committed = true;
842
+ } finally {
843
+ if (!committed) {
844
+ try {
845
+ database.exec('ROLLBACK');
846
+ } catch {}
847
+ }
848
+ }
849
+ }
850
+
851
+ function markRouteFailed(
852
+ database: ReceptionDatabase,
853
+ messageId: string,
854
+ cityId: string,
855
+ error: unknown,
856
+ ): void {
857
+ const reason = String(error instanceof Error ? error.message : error).slice(0, 300);
858
+ const row = database
859
+ .prepare(
860
+ `
861
+ SELECT attempt_count FROM reception_routes
862
+ WHERE message_id = ? AND target_city_id = ? AND state = 'queued'
863
+ `,
864
+ )
865
+ .get(messageId, cityId) as { attempt_count?: number } | undefined;
866
+ const attempts = Number(row?.attempt_count ?? 0) + 1;
867
+ const now = Date.now();
868
+ const retryAt = now + Math.min(300_000, 1_000 * 2 ** Math.min(attempts - 1, 8));
869
+ database
870
+ .prepare(
871
+ `
872
+ UPDATE reception_routes
873
+ SET attempt_count = ?, last_attempt_at = ?, next_attempt_at = ?, error = ?
874
+ WHERE message_id = ? AND target_city_id = ? AND state = 'queued'
875
+ `,
876
+ )
877
+ .run(attempts, now, retryAt, reason, messageId, cityId);
878
+ }
879
+
880
+ function optionalText(value: unknown, maximum: number): string | null {
881
+ return typeof value === 'string' && value ? value.slice(0, maximum) : null;
882
+ }
883
+
884
+ function sha256(value: string): string {
885
+ return createHash('sha256').update(value, 'utf8').digest('hex');
886
+ }
887
+
888
+ function boundedInteger(
889
+ raw: string | undefined,
890
+ fallback: number,
891
+ minimum: number,
892
+ maximum: number,
893
+ ): number {
894
+ const value = Number(raw);
895
+ return Number.isSafeInteger(value) && value >= minimum && value <= maximum ? value : fallback;
896
+ }