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
@@ -6,7 +6,7 @@ import { WebSocket, WebSocketServer } from 'ws';
6
6
  import { committeeFiles } from './committee/storage.js';
7
7
  import { committeeService } from './committee/service.js';
8
8
  import { loadCityContext } from './city-config.js';
9
- import { drainRoadQueue } from './delivery-queue.js';
9
+ import { acknowledgeRoadQueue, pendingRoadQueue } from './delivery-queue.js';
10
10
  import {
11
11
  ACTOR_RE,
12
12
  ActorCredential,
@@ -163,11 +163,12 @@ server.listen(0, '127.0.0.1', () => {
163
163
  };
164
164
  publishEndpoint(context, endpoint);
165
165
  diagnostics('hub.listening', { outcome: 'ready', message: endpoint.url });
166
- for (const envelope of drainRoadQueue(context.runtimeDir)) {
166
+ for (const queued of pendingRoadQueue(context.runtimeDir)) {
167
167
  try {
168
- roads.inbound(envelope);
168
+ roads.inbound(queued.envelope);
169
+ acknowledgeRoadQueue(queued.queueFile);
169
170
  } catch (error) {
170
- console.error(`[city-bus] dropped queued road envelope: ${(error as Error).message}`);
171
+ console.error(`[city-bus] kept queued Road envelope: ${(error as Error).message}`);
171
172
  }
172
173
  }
173
174
  roads.start();
@@ -0,0 +1,208 @@
1
+ import type WebSocket from 'ws';
2
+ import type { CityContext } from '../city-config.js';
3
+ import { BUS_PROTOCOL, isoNow, type BusEnvelope, type Road } from '../protocol.js';
4
+ import type { ManagedRelaySession, UntrustedRoadText } from './relay-session.js';
5
+ import {
6
+ connectedStateForCity,
7
+ loadConnectIdentity,
8
+ refreshStoredTransparency,
9
+ } from './storage.js';
10
+ import { openManagedRelaySession } from './transport.js';
11
+
12
+ const INITIAL_BACKOFF_MS = 1_000;
13
+ const MAX_BACKOFF_MS = 30_000;
14
+
15
+ export function managedRoadBridge(
16
+ context: CityContext,
17
+ receive: (envelope: BusEnvelope) => void,
18
+ receiveManaged?: (envelope: BusEnvelope) => { inserted: boolean },
19
+ ) {
20
+ let session: ManagedRelaySession | null = null;
21
+ let socket: WebSocket | null = null;
22
+ let stopped = false;
23
+ let connecting = false;
24
+ let timer: ReturnType<typeof setTimeout> | null = null;
25
+ let heartbeat: ReturnType<typeof setInterval> | null = null;
26
+ let backoff = INITIAL_BACKOFF_MS;
27
+ let cachedRoads: Road[] = [];
28
+ let configured = false;
29
+ let warnedState = false;
30
+
31
+ const stateForCity = () => {
32
+ try {
33
+ const found = connectedStateForCity(context.city.id, context.appHome);
34
+ configured = Boolean(found);
35
+ return found;
36
+ } catch (error) {
37
+ configured = false;
38
+ if (!warnedState) {
39
+ warnedState = true;
40
+ console.error(`[city-bus] managed Connect unavailable: ${(error as Error).message}`);
41
+ }
42
+ return null;
43
+ }
44
+ };
45
+
46
+ const updateCache = (): Road[] => {
47
+ if (session) {
48
+ cachedRoads = session.roads().map((road) => ({
49
+ id: road.id,
50
+ name: road.peerCity.split('/')[1] || road.peerCity,
51
+ owner: road.peerCity.split('/')[0] || 'remote',
52
+ address: road.peerCity,
53
+ local: false,
54
+ managed: true,
55
+ revision: road.revision,
56
+ }));
57
+ }
58
+ return cachedRoads.map((road) => ({ ...road }));
59
+ };
60
+
61
+ const schedule = (): void => {
62
+ if (stopped || timer) return;
63
+ timer = setTimeout(() => {
64
+ timer = null;
65
+ void connect();
66
+ }, backoff);
67
+ timer.unref();
68
+ backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
69
+ };
70
+
71
+ const disconnected = (): void => {
72
+ if (heartbeat) clearInterval(heartbeat);
73
+ heartbeat = null;
74
+ session = null;
75
+ socket = null;
76
+ connecting = false;
77
+ if (!stopped) schedule();
78
+ };
79
+
80
+ const connect = async (): Promise<void> => {
81
+ if (stopped || connecting || session) return;
82
+ const found = stateForCity();
83
+ if (!found) {
84
+ schedule();
85
+ return;
86
+ }
87
+ connecting = true;
88
+ try {
89
+ const refreshed = await refreshStoredTransparency(context.appHome);
90
+ if (refreshed.state.status !== 'connected') throw new Error('connect_state_not_connected');
91
+ if (refreshed.refreshWarning && process.env.CITY_BUS_DEBUG === '1') {
92
+ console.error(`[city-bus] ${refreshed.refreshWarning}; using unexpired cached root`);
93
+ }
94
+ const opened = await openManagedRelaySession(
95
+ await loadConnectIdentity(refreshed.state, context.appHome),
96
+ found.binding.remoteAddress,
97
+ {
98
+ onText: async (message) => {
99
+ const envelope = managedEnvelope(context, message);
100
+ if (!receiveManaged) throw new Error('managed_reception_receipt_unavailable');
101
+ const result = receiveManaged(envelope);
102
+ return {
103
+ messageId: message.messageId,
104
+ status: result.inserted ? 'inserted' : 'duplicate',
105
+ };
106
+ },
107
+ keyTransparency: refreshed.runtime,
108
+ onSecurityError: (error) => {
109
+ console.error(`[city-bus] managed Road frame rejected: ${error.message}`);
110
+ },
111
+ onLocalError: (error) => {
112
+ console.error(`[city-bus] managed Road local handoff failed: ${error.message}`);
113
+ },
114
+ },
115
+ );
116
+ if (stopped) {
117
+ opened.session.close();
118
+ return;
119
+ }
120
+ session = opened.session;
121
+ socket = opened.socket;
122
+ updateCache();
123
+ backoff = INITIAL_BACKOFF_MS;
124
+ warnedState = false;
125
+ connecting = false;
126
+ opened.socket.once('close', disconnected);
127
+ heartbeat = setInterval(() => {
128
+ try {
129
+ session?.ping();
130
+ } catch {}
131
+ }, 30_000);
132
+ heartbeat.unref();
133
+ } catch (error) {
134
+ connecting = false;
135
+ if (process.env.CITY_BUS_DEBUG === '1') {
136
+ console.error(`[city-bus] managed Connect retry: ${(error as Error).message}`);
137
+ }
138
+ schedule();
139
+ }
140
+ };
141
+
142
+ const send = async (to: string, envelope: BusEnvelope): Promise<string> => {
143
+ const active = session;
144
+ if (!active) throw new Error('the managed Road is not connected');
145
+ const matches = active.roads().filter((road) => road.peerCity === to);
146
+ if (matches.length !== 1) {
147
+ throw new Error(
148
+ matches.length ? 'multiple managed Roads share that address' : 'managed Road not available',
149
+ );
150
+ }
151
+ const body = envelope.payload?.text;
152
+ if (typeof body !== 'string') throw new Error('managed Roads carry text only');
153
+ const result = await active.sendRoadText(matches[0].id, body);
154
+ if (result.status === 'duplicate') return `duplicate already accepted by ${to}`;
155
+ return `encrypted message durably queued for ${to}`;
156
+ };
157
+
158
+ const close = (): void => {
159
+ stopped = true;
160
+ if (timer) clearTimeout(timer);
161
+ if (heartbeat) clearInterval(heartbeat);
162
+ timer = null;
163
+ heartbeat = null;
164
+ try {
165
+ session?.close();
166
+ } catch {}
167
+ try {
168
+ socket?.close();
169
+ } catch {}
170
+ session = null;
171
+ socket = null;
172
+ };
173
+
174
+ return {
175
+ start: () => {
176
+ void connect();
177
+ },
178
+ close,
179
+ send,
180
+ roads: updateCache,
181
+ hasRoad: (address: string) => updateCache().some((road) => road.address === address),
182
+ enabled: () => configured || Boolean(stateForCity()),
183
+ online: (address: string) =>
184
+ Boolean(session) && updateCache().some((road) => road.address === address),
185
+ };
186
+ }
187
+
188
+ export type ManagedRoadBridge = ReturnType<typeof managedRoadBridge>;
189
+
190
+ function managedEnvelope(context: CityContext, message: UntrustedRoadText): BusEnvelope {
191
+ return {
192
+ protocol: BUS_PROTOCOL,
193
+ id: `managed_${message.messageId.replaceAll('-', '')}`,
194
+ kind: 'road.message',
195
+ scope: 'road',
196
+ thread: null,
197
+ from: { city: message.from, actor: 'seat', role: 'external-seat' },
198
+ to: { city: context.city.address, actor: 'seat' },
199
+ createdAt: isoNow(),
200
+ payload: {
201
+ text: message.text,
202
+ trust: 'information-not-authority',
203
+ transport: 'managed-e2ee',
204
+ remoteMessageId: message.messageId,
205
+ roadId: message.roadId,
206
+ },
207
+ };
208
+ }
@@ -0,0 +1,416 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { hostname, platform } from 'node:os';
3
+ import { existsSync } from 'node:fs';
4
+ import {
5
+ beginDeviceAuthorization,
6
+ ConnectApiError,
7
+ listDeviceRoads,
8
+ pollDeviceAuthorization,
9
+ syncDeviceCities,
10
+ type DeviceIdentity,
11
+ } from './device.js';
12
+ import { discoverLocalCities, selectLocalCities, type LocalCity } from './local-cities.js';
13
+ import {
14
+ CONNECT_STATE_PROTOCOL,
15
+ agentsCityHome,
16
+ loadConnectIdentity,
17
+ loadOrCreateConnectKeys,
18
+ loadTransparencyProfile,
19
+ normalizeConnectServiceUrl,
20
+ readConnectState,
21
+ refreshStoredTransparency,
22
+ removePendingConnectState,
23
+ resolveStoredTransparency,
24
+ writeConnectState,
25
+ type ConnectedConnectState,
26
+ type ConnectedCityBinding,
27
+ type PendingConnectState,
28
+ type StoredTransparencyProfile,
29
+ } from './storage.js';
30
+ import { ensureHub } from '../hub-client.js';
31
+ import { loadCityContext } from '../city-config.js';
32
+ import { readEndpoint } from '../runtime-files.js';
33
+
34
+ type CliOptions = {
35
+ serviceUrl: string;
36
+ selectors: string[];
37
+ all: boolean;
38
+ openBrowser: boolean;
39
+ trustFile: string;
40
+ json: boolean;
41
+ command: 'connect' | 'status' | 'roads' | 'help';
42
+ };
43
+
44
+ function usage(): string {
45
+ return `usage:
46
+ agents-city connect [--city NAME | --all] [--service URL] [--trust-file FILE]
47
+ agents-city connect status [--json]
48
+ agents-city connect roads [--json]
49
+
50
+ The first pairing must include --service URL (or AGENTS_CITY_CONNECT_URL). It
51
+ creates this computer's signing and encryption keys locally,
52
+ prints a one-use PASCO, opens the service for approval, and starts the owner
53
+ reception bridge from the selected local city hub. A person connection never
54
+ reveals or selects that city. No private key is uploaded. Add --no-open when you
55
+ want to open the URL by hand. Later calls reuse the service recorded in the local
56
+ device state. A self-hosted service must provide its signed, versioned root
57
+ chain through --trust-file. Passing a newer chain later rotates operator or
58
+ witness keys without silently replacing trust. The official managed service
59
+ ships its initial root with Agents City before production is enabled.`;
60
+ }
61
+
62
+ function optionsOf(args: string[]): CliOptions {
63
+ const options: CliOptions = {
64
+ serviceUrl: process.env.AGENTS_CITY_CONNECT_URL || '',
65
+ selectors: [],
66
+ all: false,
67
+ openBrowser: true,
68
+ trustFile: '',
69
+ json: false,
70
+ command: 'connect',
71
+ };
72
+ const rest = [...args];
73
+ if (['status', 'roads', 'help'].includes(rest[0] || '')) {
74
+ options.command = rest.shift() as CliOptions['command'];
75
+ }
76
+ while (rest.length) {
77
+ const arg = rest.shift() || '';
78
+ if (arg === '--service') {
79
+ const value = rest.shift();
80
+ if (!value) throw new Error('--service needs an https URL');
81
+ options.serviceUrl = value;
82
+ } else if (arg === '--city') {
83
+ const value = rest.shift();
84
+ if (!value) throw new Error('--city needs a name, id, or path');
85
+ options.selectors.push(value);
86
+ } else if (arg === '--trust-file') {
87
+ const value = rest.shift();
88
+ if (!value) throw new Error('--trust-file needs a JSON file');
89
+ options.trustFile = value;
90
+ } else if (arg === '--all') options.all = true;
91
+ else if (arg === '--no-open') options.openBrowser = false;
92
+ else if (arg === '--json') options.json = true;
93
+ else if (['-h', '--help'].includes(arg)) options.command = 'help';
94
+ else throw new Error(`unknown connect option: ${arg}`);
95
+ }
96
+ return options;
97
+ }
98
+
99
+ function openUrl(url: string): void {
100
+ const command = process.platform === 'darwin' ? 'open' : 'xdg-open';
101
+ try {
102
+ const child = spawn(command, [url], { detached: true, stdio: 'ignore' });
103
+ child.on('error', () => {});
104
+ child.unref();
105
+ } catch {}
106
+ }
107
+
108
+ function remainingAuthorization(
109
+ state: PendingConnectState,
110
+ ): PendingConnectState['authorization'] | null {
111
+ const started = Date.parse(state.createdAt);
112
+ const elapsed = Number.isFinite(started)
113
+ ? Math.max(0, Date.now() - started)
114
+ : Number.POSITIVE_INFINITY;
115
+ const seconds = Math.floor(state.authorization.expires_in - elapsed / 1_000);
116
+ return seconds > 0 ? { ...state.authorization, expires_in: seconds } : null;
117
+ }
118
+
119
+ async function identityFor(
120
+ serviceUrl: string,
121
+ openBrowser: boolean,
122
+ keyTransparency: StoredTransparencyProfile,
123
+ ): Promise<{
124
+ identity: DeviceIdentity;
125
+ connectedAt: string;
126
+ previous: ConnectedConnectState | null;
127
+ }> {
128
+ const current = readConnectState();
129
+ if (
130
+ current?.serviceUrl !== undefined &&
131
+ normalizedService(current.serviceUrl) !== normalizedService(serviceUrl)
132
+ ) {
133
+ throw new Error(
134
+ `this computer is already paired with ${current.serviceUrl}; use that service URL`,
135
+ );
136
+ }
137
+ if (current?.status === 'connected') {
138
+ return {
139
+ identity: await loadConnectIdentity(current),
140
+ connectedAt: current.connectedAt,
141
+ previous: current,
142
+ };
143
+ }
144
+
145
+ let pending = current?.status === 'pending' ? current : null;
146
+ let authorization = pending ? remainingAuthorization(pending) : null;
147
+ const { vault, keys } = await loadOrCreateConnectKeys();
148
+ if (!authorization) {
149
+ if (pending) removePendingConnectState();
150
+ const machineName = hostname().slice(0, 100) || 'Agents City computer';
151
+ authorization = await beginDeviceAuthorization(serviceUrl, machineName, platform(), keys);
152
+ pending = {
153
+ protocol: CONNECT_STATE_PROTOCOL,
154
+ status: 'pending',
155
+ serviceUrl,
156
+ machineName,
157
+ createdAt: new Date().toISOString(),
158
+ authorization,
159
+ keyTransparency,
160
+ };
161
+ writeConnectState(pending);
162
+ }
163
+ if (!pending || !authorization) throw new Error('device authorization could not start');
164
+
165
+ process.stdout.write(`\n PASCO ${authorization.user_code}\n`);
166
+ process.stdout.write(` Open ${authorization.verification_uri}\n`);
167
+ process.stdout.write(' Approve this computer there; waiting for approval…\n\n');
168
+ if (openBrowser) openUrl(authorization.verification_uri);
169
+ try {
170
+ const identity = await pollDeviceAuthorization(serviceUrl, authorization, keys, {
171
+ onPending: () => {},
172
+ });
173
+ await vault.saveIdentity(identity);
174
+ return { identity, connectedAt: new Date().toISOString(), previous: null };
175
+ } catch (error) {
176
+ if (
177
+ error instanceof ConnectApiError &&
178
+ ['access_denied', 'expired_token', 'device_code_consumed'].includes(error.code)
179
+ )
180
+ removePendingConnectState();
181
+ throw error;
182
+ }
183
+ }
184
+
185
+ const normalizedService = (value: string): string => normalizeConnectServiceUrl(value);
186
+
187
+ function mergeCities(local: LocalCity[], previous: ConnectedConnectState | null): LocalCity[] {
188
+ const existingIds = new Set(
189
+ (previous?.cities ?? [])
190
+ .filter((city) => city.connected && existsSync(city.dataDir))
191
+ .map((city) => city.localCityId),
192
+ );
193
+ const all = discoverLocalCities();
194
+ const retained = all.filter((city) => existingIds.has(city.id));
195
+ return [...retained, ...local].filter(
196
+ (city, index, values) => values.findIndex((candidate) => candidate.id === city.id) === index,
197
+ );
198
+ }
199
+
200
+ async function restartHub(city: LocalCity): Promise<void> {
201
+ const context = loadCityContext(city.dataDir);
202
+ const endpoint = readEndpoint(context);
203
+ if (endpoint && endpoint.dataDir === context.dataDir && endpoint.pid !== process.pid) {
204
+ try {
205
+ process.kill(endpoint.pid, 'SIGTERM');
206
+ } catch {}
207
+ const deadline = Date.now() + 2_500;
208
+ while (Date.now() < deadline) {
209
+ try {
210
+ process.kill(endpoint.pid, 0);
211
+ } catch {
212
+ break;
213
+ }
214
+ await new Promise((resolve) => setTimeout(resolve, 50));
215
+ }
216
+ try {
217
+ process.kill(endpoint.pid, 0);
218
+ throw new Error(`local bus ${endpoint.pid} did not stop for managed Connect`);
219
+ } catch (error) {
220
+ if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error;
221
+ }
222
+ }
223
+ await ensureHub(context);
224
+ }
225
+
226
+ function bindingsFrom(
227
+ local: LocalCity[],
228
+ remote: Awaited<ReturnType<typeof syncDeviceCities>>['cities'],
229
+ ): ConnectedCityBinding[] {
230
+ const bySlug = new Map(local.map((city) => [city.slug, city]));
231
+ return remote.map((city) => {
232
+ const slug = city.address.split('/')[1] || '';
233
+ const source = bySlug.get(slug);
234
+ if (!source) throw new Error(`service returned an unknown city: ${city.address}`);
235
+ return {
236
+ localCityId: source.id,
237
+ dataDir: source.dataDir,
238
+ slug: source.slug,
239
+ name: source.name,
240
+ remoteAddress: city.address,
241
+ encryptionKeyId: city.encryption_key_id,
242
+ connected: city.connected,
243
+ };
244
+ });
245
+ }
246
+
247
+ async function connect(options: CliOptions): Promise<void> {
248
+ agentsCityHome();
249
+ const remembered = readConnectState()?.serviceUrl ?? '';
250
+ if (!options.serviceUrl && !remembered) {
251
+ throw new Error('first pairing needs --service URL or AGENTS_CITY_CONNECT_URL');
252
+ }
253
+ options.serviceUrl = normalizedService(options.serviceUrl || remembered);
254
+ let existing = readConnectState();
255
+ if (existing && !options.trustFile && !process.env.AGENTS_CITY_CONNECT_TRUST_FILE) {
256
+ const refreshed = await refreshStoredTransparency();
257
+ existing = refreshed.state;
258
+ if (refreshed.refreshWarning && !options.json) {
259
+ console.error(` Warning: ${refreshed.refreshWarning}; using the unexpired local root.`);
260
+ }
261
+ }
262
+ const transparency = await loadTransparencyProfile(
263
+ options.serviceUrl,
264
+ options.trustFile,
265
+ existing?.keyTransparency.root ?? null,
266
+ );
267
+ const catalogue = discoverLocalCities();
268
+ const chosen = selectLocalCities(catalogue, options.selectors, options.all);
269
+ const paired = await identityFor(options.serviceUrl, options.openBrowser, transparency.stored);
270
+ if (transparency.stored.root.signed.relayUrl !== paired.identity.relayUrl) {
271
+ throw new Error('key_transparency_relay_mismatch');
272
+ }
273
+ const cities = mergeCities(chosen, paired.previous);
274
+ if (new Set(cities.map((city) => city.slug)).size !== cities.length) {
275
+ throw new Error(
276
+ 'two selected local cities have the same slug; their managed addresses would collide',
277
+ );
278
+ }
279
+ const synced = await syncDeviceCities(
280
+ options.serviceUrl,
281
+ paired.identity,
282
+ cities.map((city) => ({ slug: city.slug, name: city.name, connected: true })),
283
+ );
284
+ const now = new Date().toISOString();
285
+ const state: ConnectedConnectState = {
286
+ protocol: CONNECT_STATE_PROTOCOL,
287
+ status: 'connected',
288
+ serviceUrl: normalizedService(options.serviceUrl),
289
+ connectedAt: paired.connectedAt,
290
+ updatedAt: now,
291
+ device: {
292
+ deviceId: paired.identity.deviceId,
293
+ ownerPrefix: paired.identity.ownerPrefix,
294
+ relayUrl: paired.identity.relayUrl,
295
+ keyVersion: paired.identity.keyVersion,
296
+ },
297
+ keyTransparency: transparency.stored,
298
+ cities: bindingsFrom(cities, synced.cities),
299
+ };
300
+ writeConnectState(state);
301
+ for (const city of chosen) await restartHub(city);
302
+ const value = {
303
+ connected: true,
304
+ deviceId: state.device.deviceId,
305
+ service: state.serviceUrl,
306
+ cities: state.cities.map((city) => ({ local: city.name, address: city.remoteAddress })),
307
+ };
308
+ if (options.json) console.log(JSON.stringify(value, null, 2));
309
+ else {
310
+ console.log(` Connected ${chosen.map((city) => city.name).join(', ')}.`);
311
+ for (const city of state.cities) console.log(` ${city.name}: ${city.remoteAddress}`);
312
+ console.log(" This computer's reception now keeps one outbound encrypted connection open.\n");
313
+ }
314
+ }
315
+
316
+ async function status(options: CliOptions): Promise<void> {
317
+ const state = readConnectState();
318
+ if (!state) throw new Error('this computer has not been paired; run agents-city connect');
319
+ await resolveStoredTransparency(state);
320
+ if (state.status === 'pending') {
321
+ const value = {
322
+ status: 'pending',
323
+ service: state.serviceUrl,
324
+ pasco: state.authorization.user_code,
325
+ trustRootVersion: state.keyTransparency.root.signed.version,
326
+ trustRootExpiresAt: new Date(state.keyTransparency.root.signed.expiresAt).toISOString(),
327
+ };
328
+ if (options.json) console.log(JSON.stringify(value, null, 2));
329
+ else
330
+ console.log(
331
+ ` Pending approval at ${state.serviceUrl}\n PASCO ${state.authorization.user_code}`,
332
+ );
333
+ return;
334
+ }
335
+ const value = {
336
+ status: 'connected',
337
+ service: state.serviceUrl,
338
+ deviceId: state.device.deviceId,
339
+ trustRootVersion: state.keyTransparency.root.signed.version,
340
+ trustRootExpiresAt: new Date(state.keyTransparency.root.signed.expiresAt).toISOString(),
341
+ cities: state.cities.map((city) => ({
342
+ name: city.name,
343
+ address: city.remoteAddress,
344
+ connected: city.connected,
345
+ })),
346
+ };
347
+ if (options.json) console.log(JSON.stringify(value, null, 2));
348
+ else {
349
+ console.log(` Connected to ${state.serviceUrl}`);
350
+ for (const city of state.cities)
351
+ console.log(` ${city.connected ? '●' : '○'} ${city.name} ${city.remoteAddress}`);
352
+ }
353
+ }
354
+
355
+ async function roads(options: CliOptions): Promise<void> {
356
+ const state = readConnectState();
357
+ if (!state || state.status !== 'connected') throw new Error('this computer is not connected');
358
+ const refreshed = await refreshStoredTransparency();
359
+ if (refreshed.state.status !== 'connected') throw new Error('this computer is not connected');
360
+ const directory = await listDeviceRoads(
361
+ refreshed.state.serviceUrl,
362
+ await loadConnectIdentity(refreshed.state),
363
+ );
364
+ type ListedRoad =
365
+ | {
366
+ id: string;
367
+ kind: 'connection';
368
+ connectionId: string | null;
369
+ person: string;
370
+ revision: number;
371
+ }
372
+ | {
373
+ id: string;
374
+ kind: 'city';
375
+ from: string;
376
+ to: string;
377
+ purpose: string | null;
378
+ revision: number;
379
+ };
380
+ const value = directory.roads.map((road): ListedRoad =>
381
+ road.kind === 'connection'
382
+ ? {
383
+ id: road.id,
384
+ kind: 'connection',
385
+ connectionId: road.connectionId,
386
+ person: road.peerName,
387
+ revision: road.revision,
388
+ }
389
+ : {
390
+ id: road.id,
391
+ kind: 'city',
392
+ from: road.localCity,
393
+ to: road.peerCity,
394
+ purpose: road.purpose,
395
+ revision: road.revision,
396
+ },
397
+ );
398
+ if (options.json) console.log(JSON.stringify(value, null, 2));
399
+ else if (!value.length) console.log(' No active managed Roads.');
400
+ else
401
+ for (const road of value) {
402
+ if (road.kind === 'connection') console.log(` Person ${road.person}`);
403
+ else console.log(` ${road.from} → ${road.to}${road.purpose ? ` ${road.purpose}` : ''}`);
404
+ }
405
+ }
406
+
407
+ export async function managedConnectCli(args = process.argv.slice(2)): Promise<void> {
408
+ const options = optionsOf(args);
409
+ if (options.command === 'help') {
410
+ console.log(usage());
411
+ return;
412
+ }
413
+ if (options.command === 'status') return status(options);
414
+ if (options.command === 'roads') return roads(options);
415
+ return connect(options);
416
+ }
@@ -0,0 +1,15 @@
1
+ // The generated module is exported from agents-city-web with a source manifest.
2
+ // Keep this facade small so the public CLI cannot drift onto a second crypto stack.
3
+ export {
4
+ ConnectApiError,
5
+ beginDeviceAuthorization,
6
+ listDeviceRoads,
7
+ pollDeviceAuthorization,
8
+ signedRelayHeaders,
9
+ syncDeviceCities,
10
+ type DeviceAuthorization,
11
+ type DeviceIdentity,
12
+ type DeviceKeys,
13
+ type DeviceRoad,
14
+ type KeyTransparencyTrust,
15
+ } from '../managed-connect-client.js';