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,609 @@
1
+ #!/usr/bin/env node
2
+ import assert from 'node:assert/strict';
3
+ import { createHash, randomBytes, randomUUID } from 'node:crypto';
4
+ import { createServer } from 'node:http';
5
+ import {
6
+ chmodSync,
7
+ lstatSync,
8
+ mkdirSync,
9
+ mkdtempSync,
10
+ readFileSync,
11
+ rmSync,
12
+ symlinkSync,
13
+ unlinkSync,
14
+ writeFileSync,
15
+ } from 'node:fs';
16
+ import { tmpdir } from 'node:os';
17
+ import { join } from 'node:path';
18
+ import { spawn, spawnSync } from 'node:child_process';
19
+ import {
20
+ HYBRID_ESTABLISHMENT_SUITE,
21
+ KEY_TRANSPARENCY_PROTOCOL,
22
+ KEY_TRANSPARENCY_ROOT_CHAIN_PROTOCOL,
23
+ KEY_TRANSPARENCY_ROOT_PROTOCOL,
24
+ RELAY_PROTOCOL,
25
+ SEALED_DELIVERY_PROTOCOL,
26
+ SEALED_SUITE,
27
+ createHybridSenderSecret,
28
+ createKeyTransparencyRootSignature,
29
+ createRoadEnvelope,
30
+ createSealedRoadSubmission,
31
+ deriveHybridRecipientSecret,
32
+ generateDeviceKeys,
33
+ generateMlKem768Prekey,
34
+ hashKeyTransparencyRoot,
35
+ initializeHybridCrypto,
36
+ openHybridEstablishment,
37
+ openRoadEnvelope,
38
+ openSealedRoadDelivery,
39
+ randomHybridNonce,
40
+ randomKemEncapsulation,
41
+ sealHybridEstablishment,
42
+ wipeHybridSecret,
43
+ } from '../plugin/channel/managed-connect-client.js';
44
+
45
+ const ROOT = new URL('..', import.meta.url).pathname;
46
+ const CLI = new URL('../plugin/channel/managed-connect-cli.js', import.meta.url).pathname;
47
+ const checks = [];
48
+ const check = (name, condition, detail = '') => {
49
+ assert.ok(condition, `${name}${detail ? `: ${detail}` : ''}`);
50
+ checks.push(name);
51
+ };
52
+ const digest = (path) => createHash('sha256').update(readFileSync(path)).digest('hex');
53
+ const b64 = (bytes = 32) => randomBytes(bytes).toString('base64url');
54
+ const publicJwk = (jwk) => ({ kty: jwk.kty, crv: jwk.crv, x: jwk.x, ext: true });
55
+
56
+ function exportedArtifactBoundary() {
57
+ const manifestPath = join(ROOT, 'plugin/channel/managed-connect-client.manifest.json');
58
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
59
+ check('the generated public artifact has a versioned provenance manifest',
60
+ manifest.protocol === 'agents-city-public-connect-client/1');
61
+ check('every exported runtime asset matches its recorded SHA-256',
62
+ Object.entries(manifest.exported).every(([path, expected]) => (
63
+ digest(join(ROOT, path)) === expected
64
+ )));
65
+ const declarations = readFileSync(
66
+ join(ROOT, 'plugin/channel/managed-connect-client.d.ts'), 'utf8',
67
+ );
68
+ check('the public API makes durable outbound acceptance explicit',
69
+ declarations.includes('onAccepted?') && declarations.includes('messageId?: string'));
70
+ const runtime = readFileSync(join(ROOT, 'plugin/channel/managed-connect-client.js'), 'utf8');
71
+ check('the shipped runtime is relay v4 and contains no relay v2 fallback',
72
+ runtime.includes('agents-city-relay/4') && !runtime.includes('agents-city-relay/2'));
73
+ const sandboxRoots = readFileSync(
74
+ join(ROOT, 'plugin/channel/trust/agents-city-sandbox-roots.json'),
75
+ 'utf8',
76
+ );
77
+ check('the managed sandbox pin ships as public versioned root metadata',
78
+ sandboxRoots.includes('agents-city-key-transparency-root-chain/1')
79
+ && !sandboxRoots.includes('"d"'));
80
+ }
81
+
82
+ function documentationBoundary() {
83
+ const managed = readFileSync(join(ROOT, 'docs/managed-connect.md'), 'utf8');
84
+ const security = readFileSync(join(ROOT, 'docs/security.md'), 'utf8');
85
+ const english = readFileSync(join(ROOT, 'README.md'), 'utf8');
86
+ const spanish = readFileSync(join(ROOT, 'README.es.md'), 'utf8');
87
+ const publicWords = `${managed}\n${security}\n${english}\n${spanish}`;
88
+ check('public documentation names relay v4 and its three limited privacy claims',
89
+ managed.includes('agents-city-relay/4')
90
+ && managed.includes('sealed delivery')
91
+ && managed.includes('key transparency')
92
+ && managed.includes('hybrid post-quantum session establishment'));
93
+ check('public documentation describes the OS-protected encrypted vault',
94
+ managed.includes('macOS Keychain')
95
+ && /Windows Credential\s+Manager/.test(managed)
96
+ && /Linux Secret\s+Service/.test(managed)
97
+ && managed.includes('no plaintext fallback'));
98
+ check('public documentation keeps production and local-at-rest limits explicit',
99
+ managed.includes('not production-enabled')
100
+ && managed.includes('not itself application-level encrypted')
101
+ && managed.includes('independent security audit'));
102
+ check('both READMEs require a signed root chain in first-pairing examples',
103
+ english.includes('--service https://connect.example.com --trust-file roots.json')
104
+ && spanish.includes('--service https://connect.example.com --trust-file roots.json'));
105
+ check('obsolete HPKE and relay-v2 documentation is gone',
106
+ !/\bHPKE\b|protocol[- ]v?2|managed-connect-(?:core)|managed-connect\/(?:protocol|hpke|road)\.ts/.test(publicWords));
107
+ }
108
+
109
+ const identity = (keys, owner) => ({
110
+ ...keys,
111
+ deviceId: randomUUID(),
112
+ ownerPrefix: owner,
113
+ relayUrl: 'wss://relay.example.test/v1/connect',
114
+ keyVersion: 1,
115
+ });
116
+
117
+ const roadPair = (left, right) => {
118
+ const id = randomUUID();
119
+ const leftEncryptionKeyId = b64();
120
+ const rightEncryptionKeyId = b64();
121
+ const common = { id, revision: 1, establishmentSuite: SEALED_SUITE };
122
+ return [
123
+ {
124
+ ...common,
125
+ localCity: 'alice/product',
126
+ peerCity: 'bob/engineering',
127
+ localEncryptionKeyId: leftEncryptionKeyId,
128
+ peerEncryptionKeyId: rightEncryptionKeyId,
129
+ peerSigningPublicJwk: publicJwk(right.signingPublicJwk),
130
+ peerEncryptionPublicJwk: publicJwk(right.encryptionPublicJwk),
131
+ ratchetRole: 'initiator',
132
+ peerDeviceId: right.deviceId,
133
+ peerRatchetIdentityKey: right.ratchetBundle.identityKey,
134
+ peerRatchetSigningKey: right.ratchetBundle.signingKey,
135
+ peerOneTimeKeyId: right.ratchetBundle.oneTimeKeys[0].id,
136
+ peerOneTimeKey: right.ratchetBundle.oneTimeKeys[0].key,
137
+ peerHybridPrekey: null,
138
+ localHybridPrekeyId: null,
139
+ },
140
+ {
141
+ ...common,
142
+ localCity: 'bob/engineering',
143
+ peerCity: 'alice/product',
144
+ localEncryptionKeyId: rightEncryptionKeyId,
145
+ peerEncryptionKeyId: leftEncryptionKeyId,
146
+ peerSigningPublicJwk: publicJwk(left.signingPublicJwk),
147
+ peerEncryptionPublicJwk: publicJwk(left.encryptionPublicJwk),
148
+ ratchetRole: 'responder',
149
+ peerDeviceId: left.deviceId,
150
+ peerRatchetIdentityKey: left.ratchetBundle.identityKey,
151
+ peerRatchetSigningKey: left.ratchetBundle.signingKey,
152
+ peerOneTimeKeyId: left.ratchetBundle.oneTimeKeys[0].id,
153
+ peerOneTimeKey: left.ratchetBundle.oneTimeKeys[0].key,
154
+ peerHybridPrekey: null,
155
+ localHybridPrekeyId: null,
156
+ },
157
+ ];
158
+ };
159
+
160
+ async function cryptoBoundary() {
161
+ await initializeHybridCrypto();
162
+ const left = identity(await generateDeviceKeys(), 'alice');
163
+ const right = identity(await generateDeviceKeys(), 'bob');
164
+ check('device signing and encryption private keys are generated locally',
165
+ Boolean(left.signingPrivateJwk.d) && Boolean(left.encryptionPrivateJwk.d));
166
+ check('each device stages ML-KEM-768 one-time prekeys',
167
+ left.hybridPrekeys.length > 0
168
+ && Buffer.from(left.hybridPrekeys[0].publicKey, 'base64url').byteLength === 1_184
169
+ && HYBRID_ESTABLISHMENT_SUITE.includes('MLKEM768'));
170
+
171
+ const prekey = await generateMlKem768Prekey();
172
+ const sender = await createHybridSenderSecret(right.encryptionPublicJwk);
173
+ const recipientSecret = await deriveHybridRecipientSecret(
174
+ right.encryptionPrivateJwk,
175
+ sender.ephemeralKey,
176
+ );
177
+ const nonce = randomHybridNonce();
178
+ const randomness = randomKemEncapsulation();
179
+ const transcript = 'agents-city-public-export-test/1';
180
+ const hybridPlaintext = 'post-quantum establishment belongs to the two endpoints';
181
+ const hybridCiphertext = sealHybridEstablishment(
182
+ prekey.publicKey,
183
+ sender.classicalSecret,
184
+ transcript,
185
+ hybridPlaintext,
186
+ randomness,
187
+ nonce,
188
+ );
189
+ check('hybrid X25519 plus ML-KEM establishment exposes no plaintext',
190
+ !hybridCiphertext.includes(hybridPlaintext));
191
+ check('the generated public artifact opens its hybrid establishment',
192
+ openHybridEstablishment(
193
+ prekey.seed,
194
+ recipientSecret,
195
+ transcript,
196
+ hybridCiphertext,
197
+ nonce,
198
+ ) === hybridPlaintext);
199
+ assert.throws(() => openHybridEstablishment(
200
+ prekey.seed,
201
+ recipientSecret,
202
+ `${transcript}/substituted`,
203
+ hybridCiphertext,
204
+ nonce,
205
+ ));
206
+ checks.push('hybrid ciphertext is bound to its signed transcript');
207
+ wipeHybridSecret(sender.classicalSecret);
208
+ wipeHybridSecret(recipientSecret);
209
+ wipeHybridSecret(randomness);
210
+ wipeHybridSecret(nonce);
211
+
212
+ const [leftRoad, rightRoad] = roadPair(left, right);
213
+ const text = 'Review the screenshot before merging this change.';
214
+ const envelope = await createRoadEnvelope(left, leftRoad, text);
215
+ check('signed Road ciphertext contains no application plaintext',
216
+ !JSON.stringify(envelope).includes(text));
217
+ const opened = await openRoadEnvelope(right, rightRoad, envelope);
218
+ check('only the intended Road endpoint opens the signed message',
219
+ opened.status === 'pending' && opened.kind === 'text' && opened.text === text);
220
+ await right.ratchet.commitInbound(rightRoad.id, envelope.id, rightRoad.revision);
221
+
222
+ const now = Date.now();
223
+ const registrations = await right.ratchet.ensureInboundSealedCapabilities(
224
+ rightRoad.id, rightRoad.revision, 3, now,
225
+ );
226
+ await right.ratchet.confirmInboundSealedCapabilities(
227
+ registrations.map(({ receiptTag }) => receiptTag), now,
228
+ );
229
+ const capabilities = await right.ratchet.unsharedInboundSealedCapabilities(
230
+ rightRoad.id, rightRoad.revision, now,
231
+ );
232
+ await left.ratchet.acceptOutboundSealedCapabilities(
233
+ leftRoad.id, leftRoad.revision, capabilities, now,
234
+ );
235
+ const messageId = randomUUID();
236
+ const sealed = await createSealedRoadSubmission(left, leftRoad, text, {
237
+ messageId,
238
+ now: now + 1,
239
+ });
240
+ check('a stable application id becomes the sealed submission id',
241
+ sealed.submission.id === messageId);
242
+ const remaining = await left.ratchet.outboundSealedCapabilityCount(
243
+ leftRoad.id, leftRoad.revision, now + 1,
244
+ );
245
+ const retried = await createSealedRoadSubmission(left, leftRoad, text, {
246
+ messageId,
247
+ now: now + 10_000,
248
+ });
249
+ check('a crash retry returns the exact same sealed ciphertext and capability',
250
+ JSON.stringify(retried) === JSON.stringify(sealed));
251
+ check('an exact retry does not advance the ratchet or consume another capability',
252
+ await left.ratchet.outboundSealedCapabilityCount(
253
+ leftRoad.id, leftRoad.revision, now + 10_000,
254
+ ) === remaining);
255
+ await assert.rejects(createSealedRoadSubmission(left, leftRoad, `${text} changed`, {
256
+ messageId,
257
+ now: now + 10_001,
258
+ }), /sealed_message_id_conflict/);
259
+ checks.push('the same application id cannot be rebound to changed text');
260
+
261
+ const delivery = {
262
+ protocol: SEALED_DELIVERY_PROTOCOL,
263
+ id: sealed.submission.id,
264
+ receiptTag: sealed.receiptTag,
265
+ receivedAt: now + 2,
266
+ expiresAt: now + 60_000,
267
+ payload: sealed.submission.payload,
268
+ };
269
+ const sealedOpened = await openSealedRoadDelivery(right, rightRoad, delivery, now + 3);
270
+ check('sealed sender hides routing metadata while the recipient still opens the text',
271
+ sealedOpened.status === 'pending' && sealedOpened.text === text
272
+ && !JSON.stringify(sealed.submission).includes(leftRoad.localCity));
273
+ await right.ratchet.commitInbound(rightRoad.id, messageId, rightRoad.revision);
274
+ await right.ratchet.commitInboundSealedCapability(sealed.receiptTag, messageId);
275
+ check('the receiver recognizes an exact sealed replay as a duplicate',
276
+ (await openSealedRoadDelivery(right, rightRoad, delivery, now + 4)).status === 'duplicate');
277
+ }
278
+
279
+ const cli = (home, ...args) => spawnSync(process.execPath, [CLI, ...args], {
280
+ encoding: 'utf8',
281
+ env: { ...process.env, AGENTS_CITY_HOME: home },
282
+ });
283
+
284
+ const cliAsync = (home, ...args) => new Promise((resolve, reject) => {
285
+ const child = spawn(process.execPath, [CLI, ...args], {
286
+ env: { ...process.env, AGENTS_CITY_HOME: home },
287
+ stdio: ['ignore', 'pipe', 'pipe'],
288
+ });
289
+ let stdout = '';
290
+ let stderr = '';
291
+ child.stdout.setEncoding('utf8');
292
+ child.stderr.setEncoding('utf8');
293
+ child.stdout.on('data', (chunk) => { stdout += chunk; });
294
+ child.stderr.on('data', (chunk) => { stderr += chunk; });
295
+ child.once('error', reject);
296
+ child.once('close', (status) => resolve({ status, stdout, stderr }));
297
+ });
298
+
299
+ function builtInPinBoundary() {
300
+ const root = mkdtempSync(join(tmpdir(), 'agents-city-built-in-root-'));
301
+ try {
302
+ const result = cli(
303
+ root,
304
+ '--service',
305
+ 'https://agents-city-connect-sandbox.pages.dev',
306
+ '--city',
307
+ 'intentionally-missing',
308
+ '--no-open',
309
+ );
310
+ check('the official sandbox resolves its shipped root before any network pairing',
311
+ result.status !== 0
312
+ && result.stderr.includes('no local city called intentionally-missing')
313
+ && !result.stderr.includes('ENOENT')
314
+ && !result.stderr.includes('invalid_key_transparency_profile_file'),
315
+ result.stderr);
316
+ } finally {
317
+ rmSync(root, { recursive: true, force: true });
318
+ }
319
+ }
320
+
321
+ async function localStateBoundary() {
322
+ const root = mkdtempSync(join(tmpdir(), 'agents-city-connect-v4-'));
323
+ try {
324
+ const connect = join(root, '.runtime', 'connect');
325
+ mkdirSync(connect, { recursive: true, mode: 0o700 });
326
+ chmodSync(join(root, '.runtime'), 0o700);
327
+ chmodSync(connect, 0o700);
328
+ const [rootPair, operatorPair, witnessPair] = await Promise.all([
329
+ crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']),
330
+ crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']),
331
+ crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']),
332
+ ]);
333
+ const rootPublic = publicJwk(await crypto.subtle.exportKey('jwk', rootPair.publicKey));
334
+ const exportedRootPrivate = await crypto.subtle.exportKey('jwk', rootPair.privateKey);
335
+ const rootPrivate = {
336
+ kty: 'OKP', crv: 'Ed25519', x: exportedRootPrivate.x, d: exportedRootPrivate.d, ext: true,
337
+ };
338
+ const now = Date.now();
339
+ const trustRoot = {
340
+ protocol: KEY_TRANSPARENCY_ROOT_PROTOCOL,
341
+ version: 1,
342
+ environment: 'production',
343
+ controlPlaneUrl: 'https://connect.example.test',
344
+ relayUrl: 'wss://relay.example.test/v1/connect',
345
+ issuedAt: now,
346
+ expiresAt: now + 90 * 24 * 60 * 60_000,
347
+ previousRootHash: null,
348
+ keys: {
349
+ 'offline-root-test-1': rootPublic,
350
+ 'operator-test-1': publicJwk(await crypto.subtle.exportKey('jwk', operatorPair.publicKey)),
351
+ 'witness-test-1': publicJwk(await crypto.subtle.exportKey('jwk', witnessPair.publicKey)),
352
+ },
353
+ roles: {
354
+ root: { keyIds: ['offline-root-test-1'], threshold: 1 },
355
+ operator: { keyIds: ['operator-test-1'], threshold: 1 },
356
+ witness: { keyIds: ['witness-test-1'], threshold: 1 },
357
+ },
358
+ maximumHeadAgeMs: 300_000,
359
+ maximumWitnessLagMs: 60_000,
360
+ };
361
+ const rootEnvelope = {
362
+ signed: trustRoot,
363
+ signatures: [await createKeyTransparencyRootSignature(
364
+ trustRoot,
365
+ 'offline-root-test-1',
366
+ rootPrivate,
367
+ )],
368
+ };
369
+ const state = {
370
+ protocol: 'agents-city-connect-state/3',
371
+ status: 'connected',
372
+ serviceUrl: 'https://connect.example.test',
373
+ connectedAt: new Date().toISOString(),
374
+ updatedAt: new Date().toISOString(),
375
+ device: {
376
+ deviceId: randomUUID(),
377
+ ownerPrefix: 'alice',
378
+ relayUrl: 'wss://relay.example.test/v1/connect',
379
+ keyVersion: 1,
380
+ },
381
+ keyTransparency: {
382
+ root: rootEnvelope,
383
+ },
384
+ cities: [{
385
+ localCityId: 'city_local_1234',
386
+ dataDir: join(root, 'city'),
387
+ slug: 'product',
388
+ name: 'Product',
389
+ remoteAddress: 'alice/product',
390
+ encryptionKeyId: b64(),
391
+ connected: true,
392
+ }],
393
+ };
394
+ const path = join(connect, 'device.json');
395
+ writeFileSync(path, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
396
+ check('the public connect state directory is private',
397
+ (lstatSync(connect).mode & 0o777) === 0o700);
398
+ check('the public connect state file is owner-only',
399
+ (lstatSync(path).mode & 0o777) === 0o600);
400
+ const status = cli(root, 'status', '--json');
401
+ check('the public CLI reads a v4-compatible keyless device assignment',
402
+ status.status === 0
403
+ && JSON.parse(status.stdout).deviceId === state.device.deviceId
404
+ && JSON.parse(status.stdout).trustRootVersion === 1,
405
+ status.stderr);
406
+ check('device.json and status output contain no private JWK material',
407
+ !readFileSync(path, 'utf8').includes('"d"') && !status.stdout.includes('"d"'));
408
+
409
+ writeFileSync(path, JSON.stringify({ protocol: 'agents-city-connect-state/1', identity: {} }), {
410
+ mode: 0o600,
411
+ });
412
+ check('legacy plaintext-key state is refused instead of migrated silently',
413
+ cli(root, 'status', '--json').stderr.includes('legacy_connect_state_contains_plaintext_keys'));
414
+ writeFileSync(path, `${JSON.stringify(state)}\n`, { mode: 0o600 });
415
+ const tampered = structuredClone(state);
416
+ tampered.keyTransparency.root.signatures[0].signature = b64(64);
417
+ writeFileSync(path, `${JSON.stringify(tampered)}\n`, { mode: 0o600 });
418
+ check('a persisted root with a forged signature is refused before network use',
419
+ cli(root, 'status', '--json').stderr.includes(
420
+ 'insufficient_key_transparency_root_signatures',
421
+ ));
422
+ writeFileSync(path, `${JSON.stringify(state)}\n`, { mode: 0o600 });
423
+ writeFileSync(path, JSON.stringify({ protocol: 'agents-city-connect-state/2' }), {
424
+ mode: 0o600,
425
+ });
426
+ check('unversioned online trust is refused instead of becoming a root silently',
427
+ cli(root, 'status', '--json').stderr.includes(
428
+ 'connect_state_requires_versioned_trust_repairing',
429
+ ));
430
+ writeFileSync(path, `${JSON.stringify(state)}\n`, { mode: 0o600 });
431
+ chmodSync(path, 0o644);
432
+ check('over-broad state permissions are refused',
433
+ cli(root, 'status', '--json').stderr.includes('permissions_too_open'));
434
+ chmodSync(path, 0o600);
435
+ unlinkSync(path);
436
+ const target = join(root, 'attacker-state.json');
437
+ writeFileSync(target, JSON.stringify(state), { mode: 0o600 });
438
+ symlinkSync(target, path);
439
+ check('a symlinked device state is refused',
440
+ cli(root, 'status', '--json').stderr.includes('unsafe_connect_state_file'));
441
+
442
+ const unpaired = cli(join(root, 'unpaired'), '--no-open');
443
+ check('first pairing never assumes an undeployed managed service',
444
+ unpaired.status !== 0 && unpaired.stderr.includes('first pairing needs --service URL'));
445
+ const help = cli(join(root, 'help'), '--help');
446
+ check('the CLI explains PASCO approval and pinned trust before pairing',
447
+ help.status === 0 && help.stdout.includes('PASCO') && help.stdout.includes('--trust-file'));
448
+ } finally {
449
+ rmSync(root, { recursive: true, force: true });
450
+ }
451
+ }
452
+
453
+ async function rootRefreshBoundary() {
454
+ const appHome = mkdtempSync(join(tmpdir(), 'agents-city-root-refresh-'));
455
+ let chain;
456
+ const server = createServer((request, response) => {
457
+ if (request.url === '/api/key-transparency/roots?from=1' && chain) {
458
+ const body = JSON.stringify(chain);
459
+ response.writeHead(200, {
460
+ 'content-type': 'application/json',
461
+ 'content-length': Buffer.byteLength(body),
462
+ });
463
+ response.end(body);
464
+ return;
465
+ }
466
+ response.writeHead(404).end();
467
+ });
468
+ await new Promise((resolve, reject) => {
469
+ server.once('error', reject);
470
+ server.listen(0, '127.0.0.1', resolve);
471
+ });
472
+ try {
473
+ const address = server.address();
474
+ assert.ok(address && typeof address === 'object');
475
+ const serviceUrl = `http://127.0.0.1:${address.port}`;
476
+ const relayUrl = `ws://127.0.0.1:${address.port}/v1/connect`;
477
+ const [oldRootPair, newRootPair, operatorPair, witnessPair] = await Promise.all(
478
+ Array.from({ length: 4 }, () => crypto.subtle.generateKey(
479
+ { name: 'Ed25519' }, true, ['sign', 'verify'],
480
+ )),
481
+ );
482
+ const strictPrivate = async (pair) => {
483
+ const value = await crypto.subtle.exportKey('jwk', pair.privateKey);
484
+ return { kty: 'OKP', crv: 'Ed25519', x: value.x, d: value.d, ext: true };
485
+ };
486
+ const now = Date.now();
487
+ const common = {
488
+ protocol: KEY_TRANSPARENCY_ROOT_PROTOCOL,
489
+ environment: 'sandbox',
490
+ controlPlaneUrl: serviceUrl,
491
+ relayUrl,
492
+ roles: {
493
+ operator: { keyIds: ['operator-refresh-1'], threshold: 1 },
494
+ witness: { keyIds: ['witness-refresh-1'], threshold: 1 },
495
+ },
496
+ maximumHeadAgeMs: 300_000,
497
+ maximumWitnessLagMs: 30_000,
498
+ };
499
+ const onlineKeys = {
500
+ 'operator-refresh-1': publicJwk(await crypto.subtle.exportKey('jwk', operatorPair.publicKey)),
501
+ 'witness-refresh-1': publicJwk(await crypto.subtle.exportKey('jwk', witnessPair.publicKey)),
502
+ };
503
+ const first = {
504
+ ...common,
505
+ version: 1,
506
+ issuedAt: now - 48 * 60 * 60_000,
507
+ expiresAt: now - 60 * 60_000,
508
+ previousRootHash: null,
509
+ keys: {
510
+ 'root-refresh-old': publicJwk(await crypto.subtle.exportKey('jwk', oldRootPair.publicKey)),
511
+ ...onlineKeys,
512
+ },
513
+ roles: {
514
+ root: { keyIds: ['root-refresh-old'], threshold: 1 },
515
+ ...common.roles,
516
+ },
517
+ };
518
+ const firstEnvelope = {
519
+ signed: first,
520
+ signatures: [await createKeyTransparencyRootSignature(
521
+ first,
522
+ 'root-refresh-old',
523
+ await strictPrivate(oldRootPair),
524
+ )],
525
+ };
526
+ const second = {
527
+ ...common,
528
+ version: 2,
529
+ issuedAt: now - 1_000,
530
+ expiresAt: now + 90 * 24 * 60 * 60_000,
531
+ previousRootHash: await hashKeyTransparencyRoot(first),
532
+ keys: {
533
+ 'root-refresh-new': publicJwk(await crypto.subtle.exportKey('jwk', newRootPair.publicKey)),
534
+ ...onlineKeys,
535
+ },
536
+ roles: {
537
+ root: { keyIds: ['root-refresh-new'], threshold: 1 },
538
+ ...common.roles,
539
+ },
540
+ };
541
+ const secondEnvelope = {
542
+ signed: second,
543
+ signatures: [
544
+ await createKeyTransparencyRootSignature(
545
+ second,
546
+ 'root-refresh-old',
547
+ await strictPrivate(oldRootPair),
548
+ ),
549
+ await createKeyTransparencyRootSignature(
550
+ second,
551
+ 'root-refresh-new',
552
+ await strictPrivate(newRootPair),
553
+ ),
554
+ ],
555
+ };
556
+ chain = {
557
+ protocol: KEY_TRANSPARENCY_ROOT_CHAIN_PROTOCOL,
558
+ roots: [firstEnvelope, secondEnvelope],
559
+ };
560
+
561
+ const connect = join(appHome, '.runtime', 'connect');
562
+ mkdirSync(connect, { recursive: true, mode: 0o700 });
563
+ chmodSync(join(appHome, '.runtime'), 0o700);
564
+ chmodSync(connect, 0o700);
565
+ const statePath = join(connect, 'device.json');
566
+ writeFileSync(statePath, JSON.stringify({
567
+ protocol: 'agents-city-connect-state/3',
568
+ status: 'connected',
569
+ serviceUrl,
570
+ connectedAt: new Date().toISOString(),
571
+ updatedAt: new Date().toISOString(),
572
+ device: {
573
+ deviceId: randomUUID(),
574
+ ownerPrefix: 'refresh',
575
+ relayUrl,
576
+ keyVersion: 1,
577
+ },
578
+ keyTransparency: { root: firstEnvelope },
579
+ cities: [],
580
+ }), { mode: 0o600 });
581
+
582
+ const result = await cliAsync(
583
+ appHome,
584
+ '--service', serviceUrl,
585
+ '--city', 'intentionally-missing',
586
+ '--no-open',
587
+ );
588
+ const persisted = JSON.parse(readFileSync(statePath, 'utf8'));
589
+ check('an expired cached root can advance only through a valid old-and-new transition',
590
+ result.status !== 0
591
+ && persisted.keyTransparency.root.signed.version === 2
592
+ && !result.stderr.includes('expired_key_transparency_root'),
593
+ result.stderr);
594
+ } finally {
595
+ await new Promise((resolve) => server.close(resolve));
596
+ rmSync(appHome, { recursive: true, force: true });
597
+ }
598
+ }
599
+
600
+ check('the exported constants identify key transparency and relay v4',
601
+ RELAY_PROTOCOL === 'agents-city-relay/4'
602
+ && KEY_TRANSPARENCY_PROTOCOL === 'agents-city-key-transparency/1');
603
+ exportedArtifactBoundary();
604
+ documentationBoundary();
605
+ await cryptoBoundary();
606
+ builtInPinBoundary();
607
+ await localStateBoundary();
608
+ await rootRefreshBoundary();
609
+ console.log(JSON.stringify({ ok: true, checks: checks.length, names: checks }, null, 2));
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env python3
2
+ """Managed Connect: crypto, key custody and the strict relay state machine."""
3
+
4
+ import json
5
+ import os
6
+ import subprocess
7
+ import sys
8
+
9
+ AQUI = os.path.dirname(os.path.abspath(__file__))
10
+ RAIZ = os.path.dirname(AQUI)
11
+ sys.path.insert(0, AQUI)
12
+
13
+ from testlib import afirma, comprueba, resumen # noqa: E402
14
+
15
+
16
+ def main():
17
+ print("\n managed Connect, with device-held keys and ciphertext-only Roads")
18
+ script = os.path.join(AQUI, "test-connect-client.mjs")
19
+ result = subprocess.run(
20
+ ["node", script], cwd=RAIZ, capture_output=True, text=True, timeout=30)
21
+ try:
22
+ payload = json.loads(result.stdout)
23
+ except json.JSONDecodeError:
24
+ payload = {}
25
+ afirma("· the executable managed-client contract passes",
26
+ result.returncode == 0 and payload.get("ok") is True,
27
+ result.stderr or result.stdout)
28
+ comprueba("· crypto/custody/relay regressions are all exercised",
29
+ payload.get("checks"), 37)
30
+
31
+ package = json.load(open(os.path.join(RAIZ, "plugin", "channel", "package.json"),
32
+ encoding="utf-8"))
33
+ afirma("· the patched ws line is required by the public client",
34
+ package["dependencies"]["ws"].startswith("^8.21."))
35
+ afirma("· the npm front door ships a dedicated connect command",
36
+ os.access(os.path.join(RAIZ, "bin", "connect"), os.X_OK)
37
+ and "connect:" in open(os.path.join(RAIZ, "bin", "agents-city.js"),
38
+ encoding="utf-8").read())
39
+
40
+ remote = open(os.path.join(RAIZ, "plugin", "channel", "hub", "remote-roads.ts"),
41
+ encoding="utf-8").read()
42
+ controller = open(os.path.join(RAIZ, "plugin", "channel", "hub", "road-controller.ts"),
43
+ encoding="utf-8").read()
44
+ afirma("· managed Roads extend rather than replace self-hosted Roads",
45
+ "legacyRemoteRoadBridge" in remote and "managedRoadBridge" in remote)
46
+ afirma("· the one shared inbound controller still wraps every remote body",
47
+ "wrapUntrusted" in controller and "remoteRoadBridge(context" in controller)
48
+ sys.exit(resumen("connect"))
49
+
50
+
51
+ if __name__ == "__main__":
52
+ main()