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.
- package/.claude-plugin/marketplace.json +1 -1
- package/README.es.md +240 -11
- package/README.md +238 -11
- package/benchmarks/latency/fake-claude-cli.mjs +2 -1
- package/benchmarks/reception/README.md +16 -0
- package/benchmarks/reception/run.mjs +84 -0
- package/bin/agents-city.js +1 -0
- package/bin/connect +5 -0
- package/bin/hall.html +117 -18
- package/bin/navegador.mjs +170 -1
- package/bin/serve.py +201 -101
- package/bin/test +15 -3
- package/bin/test-arnes.py +192 -0
- package/bin/test-cage.py +9 -0
- package/bin/test-channel.py +178 -12
- package/bin/test-claude-runtime.py +132 -4
- package/bin/test-connect-client.mjs +609 -0
- package/bin/test-connect.py +52 -0
- package/bin/test-contracts.py +214 -0
- package/bin/test-diario.py +185 -0
- package/bin/test-doctor.py +31 -0
- package/bin/test-i18n.py +189 -0
- package/bin/test-navegador.py +61 -0
- package/bin/test-seat.py +78 -20
- package/bin/test-security.py +2 -0
- package/bin/test-serve.py +366 -0
- package/city/web/dist-hall/hall.js +789 -214
- package/city/web/src/bienvenida.ts +14 -13
- package/city/web/src/casa.ts +143 -56
- package/city/web/src/demo.ts +61 -31
- package/city/web/src/es.ts +187 -27
- package/city/web/src/explorador.ts +70 -43
- package/city/web/src/hall.ts +520 -103
- package/city/web/src/idioma.ts +21 -1
- package/city/web/src/motores.ts +48 -1
- package/city/web/src/vista.ts +58 -0
- package/demo/graba.py +5 -10
- package/docs/managed-connect.md +237 -0
- package/docs/security.md +41 -2
- package/package.json +8 -4
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/channel/adapter-prompts.ts +10 -0
- package/plugin/channel/adapter.js +78 -31
- package/plugin/channel/agents_city_hybrid_crypto_bg.wasm +0 -0
- package/plugin/channel/bus.js +109 -65
- package/plugin/channel/bus.ts +11 -1
- package/plugin/channel/client.js +67 -30
- package/plugin/channel/delivery-queue.ts +160 -21
- package/plugin/channel/hub/remote-roads.ts +32 -1
- package/plugin/channel/hub/road-controller.ts +116 -16
- package/plugin/channel/kinsh_vodozemac_wasm_bg.wasm +0 -0
- package/plugin/channel/licenses/CONNECT_CLIENT_THIRD_PARTY_NOTICES.md +21 -0
- package/plugin/channel/licenses/HYBRID_CRYPTO_THIRD_PARTY_NOTICES.md +12 -0
- package/plugin/channel/licenses/hybrid-crypto-Apache-2.0.txt +201 -0
- package/plugin/channel/licenses/keyring-MIT.txt +21 -0
- package/plugin/channel/licenses/vodozemac-Apache-2.0.txt +201 -0
- package/plugin/channel/local-hub.js +2080 -97
- package/plugin/channel/local-hub.ts +5 -4
- package/plugin/channel/managed-connect/bridge.ts +208 -0
- package/plugin/channel/managed-connect/cli.ts +416 -0
- package/plugin/channel/managed-connect/device.ts +15 -0
- package/plugin/channel/managed-connect/local-cities.ts +94 -0
- package/plugin/channel/managed-connect/person-message.ts +74 -0
- package/plugin/channel/managed-connect/reception-bridge.ts +341 -0
- package/plugin/channel/managed-connect/relay-session.ts +7 -0
- package/plugin/channel/managed-connect/storage.ts +653 -0
- package/plugin/channel/managed-connect/transport.ts +87 -0
- package/plugin/channel/managed-connect-cli.js +4777 -0
- package/plugin/channel/managed-connect-cli.ts +7 -0
- package/plugin/channel/managed-connect-client.d.ts +261 -0
- package/plugin/channel/managed-connect-client.js +6572 -0
- package/plugin/channel/managed-connect-client.manifest.json +38 -0
- package/plugin/channel/package-lock.json +123 -105
- package/plugin/channel/package.json +4 -4
- package/plugin/channel/protocol.ts +4 -0
- package/plugin/channel/reception.ts +896 -0
- package/plugin/channel/road-cli.ts +1 -1
- package/plugin/channel/runtime/arnes.json +189 -0
- package/plugin/channel/runtime/arnes.ts +47 -0
- package/plugin/channel/runtime/claude.ts +18 -0
- package/plugin/channel/runtime/codex-config.ts +51 -1
- package/plugin/channel/runtime/codex.ts +31 -11
- package/plugin/channel/runtime/kimi.ts +6 -4
- package/plugin/channel/runtime-files.ts +39 -5
- package/plugin/channel/runtime-gateway.js +426 -100
- package/plugin/channel/runtime-gateway.ts +12 -0
- package/plugin/channel/trust/agents-city-sandbox-roots.json +66 -0
- package/plugin/scripts/arnes.py +271 -0
- package/plugin/scripts/busca.py +46 -10
- package/plugin/scripts/cage.py +5 -0
- package/plugin/scripts/city-session.sh +144 -28
- package/plugin/scripts/crecimiento.py +4 -1
- package/plugin/scripts/demos.py +58 -43
- package/plugin/scripts/desinstala.py +36 -21
- package/plugin/scripts/diario.py +119 -0
- package/plugin/scripts/doctor.py +90 -15
- package/plugin/scripts/read-card.py +48 -8
- package/plugin/scripts/reception.py +665 -0
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
chmodSync,
|
|
4
|
+
closeSync,
|
|
5
|
+
constants,
|
|
6
|
+
existsSync,
|
|
7
|
+
fstatSync,
|
|
8
|
+
fsyncSync,
|
|
9
|
+
lstatSync,
|
|
10
|
+
mkdirSync,
|
|
11
|
+
openSync,
|
|
12
|
+
readFileSync,
|
|
13
|
+
realpathSync,
|
|
14
|
+
renameSync,
|
|
15
|
+
unlinkSync,
|
|
16
|
+
writeFileSync,
|
|
17
|
+
} from 'node:fs';
|
|
18
|
+
import { homedir } from 'node:os';
|
|
19
|
+
import { dirname, join, resolve } from 'node:path';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
import {
|
|
22
|
+
KEY_TRANSPARENCY_ROOT_CHAIN_PROTOCOL,
|
|
23
|
+
createOsProtectedDeviceVault,
|
|
24
|
+
initializeHybridCrypto,
|
|
25
|
+
parseKeyTransparencyRootChain,
|
|
26
|
+
resolveKeyTransparencyRootChain,
|
|
27
|
+
type DeviceAuthorization,
|
|
28
|
+
type DeviceIdentity,
|
|
29
|
+
type DeviceKeys,
|
|
30
|
+
type KeyTransparencyRootChain,
|
|
31
|
+
type KeyTransparencyRootEnvelope,
|
|
32
|
+
type KeyTransparencyTrust,
|
|
33
|
+
type NodeDeviceVault,
|
|
34
|
+
} from '../managed-connect-client.js';
|
|
35
|
+
|
|
36
|
+
export const CONNECT_STATE_PROTOCOL = 'agents-city-connect-state/3' as const;
|
|
37
|
+
const PLAINTEXT_KEY_CONNECT_STATE_PROTOCOL = 'agents-city-connect-state/1';
|
|
38
|
+
const UNVERSIONED_TRUST_CONNECT_STATE_PROTOCOL = 'agents-city-connect-state/2';
|
|
39
|
+
const MAX_STATE_BYTES = 128 * 1024;
|
|
40
|
+
const MAX_ROOT_CHAIN_BYTES = 128 * 1024;
|
|
41
|
+
const CITY_ADDRESS_RE = /^[a-z0-9][a-z0-9_-]{0,31}\/[a-z0-9][a-z0-9_-]{0,31}$/;
|
|
42
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
43
|
+
const OWNER_RE = /^[a-z0-9][a-z0-9_-]{0,31}$/;
|
|
44
|
+
const MANAGED_SANDBOX_ORIGIN = 'https://agents-city-connect-sandbox.pages.dev';
|
|
45
|
+
|
|
46
|
+
const builtInRootChain = (serviceUrl: string) =>
|
|
47
|
+
serviceUrl === MANAGED_SANDBOX_ORIGIN
|
|
48
|
+
? join(dirname(fileURLToPath(import.meta.url)), 'trust', 'agents-city-sandbox-roots.json')
|
|
49
|
+
: '';
|
|
50
|
+
|
|
51
|
+
export type ConnectedCityBinding = {
|
|
52
|
+
localCityId: string;
|
|
53
|
+
dataDir: string;
|
|
54
|
+
slug: string;
|
|
55
|
+
name: string;
|
|
56
|
+
remoteAddress: string;
|
|
57
|
+
encryptionKeyId: string;
|
|
58
|
+
connected: boolean;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export type TransparencyProfile = {
|
|
62
|
+
controlPlaneUrl: string;
|
|
63
|
+
trust: KeyTransparencyTrust;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export type StoredTransparencyProfile = {
|
|
67
|
+
root: KeyTransparencyRootEnvelope;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export type DeviceAssignment = {
|
|
71
|
+
deviceId: string;
|
|
72
|
+
ownerPrefix: string;
|
|
73
|
+
relayUrl: string;
|
|
74
|
+
keyVersion: number;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export type PendingConnectState = {
|
|
78
|
+
protocol: typeof CONNECT_STATE_PROTOCOL;
|
|
79
|
+
status: 'pending';
|
|
80
|
+
serviceUrl: string;
|
|
81
|
+
machineName: string;
|
|
82
|
+
createdAt: string;
|
|
83
|
+
authorization: DeviceAuthorization;
|
|
84
|
+
keyTransparency: StoredTransparencyProfile;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export type ConnectedConnectState = {
|
|
88
|
+
protocol: typeof CONNECT_STATE_PROTOCOL;
|
|
89
|
+
status: 'connected';
|
|
90
|
+
serviceUrl: string;
|
|
91
|
+
connectedAt: string;
|
|
92
|
+
updatedAt: string;
|
|
93
|
+
device: DeviceAssignment;
|
|
94
|
+
keyTransparency: StoredTransparencyProfile;
|
|
95
|
+
cities: ConnectedCityBinding[];
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export type ConnectState = PendingConnectState | ConnectedConnectState;
|
|
99
|
+
|
|
100
|
+
export function agentsCityHome(explicit = ''): string {
|
|
101
|
+
const requested = resolve(
|
|
102
|
+
explicit || process.env.AGENTS_CITY_HOME || join(homedir(), '.agents-city'),
|
|
103
|
+
);
|
|
104
|
+
mkdirSync(requested, { recursive: true, mode: 0o700 });
|
|
105
|
+
return realpathSync(requested);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function connectStateDirectory(appHome = ''): string {
|
|
109
|
+
return join(agentsCityHome(appHome), '.runtime', 'connect');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function connectStatePath(appHome = ''): string {
|
|
113
|
+
return join(connectStateDirectory(appHome), 'device.json');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function connectVaultDirectory(appHome = ''): string {
|
|
117
|
+
return join(connectStateDirectory(appHome), 'vault');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const vaultAccount = (appHome = '') => {
|
|
121
|
+
const digest = createHash('sha256').update(agentsCityHome(appHome)).digest('hex');
|
|
122
|
+
return `agents-city-connect-${digest}`;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
function privateDirectory(path: string): void {
|
|
126
|
+
if (!existsSync(path)) mkdirSync(path, { mode: 0o700 });
|
|
127
|
+
const info = lstatSync(path);
|
|
128
|
+
if (!info.isDirectory() || info.isSymbolicLink()) {
|
|
129
|
+
throw new Error(`unsafe_connect_state_directory:${path}`);
|
|
130
|
+
}
|
|
131
|
+
chmodSync(path, 0o700);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function prepareStateDirectory(appHome = ''): string {
|
|
135
|
+
const home = agentsCityHome(appHome);
|
|
136
|
+
const runtime = join(home, '.runtime');
|
|
137
|
+
privateDirectory(runtime);
|
|
138
|
+
const connect = join(runtime, 'connect');
|
|
139
|
+
privateDirectory(connect);
|
|
140
|
+
return connect;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function assertSafeStateDirectory(appHome = ''): string {
|
|
144
|
+
const home = agentsCityHome(appHome);
|
|
145
|
+
const runtime = join(home, '.runtime');
|
|
146
|
+
const connect = join(runtime, 'connect');
|
|
147
|
+
for (const path of [runtime, connect]) {
|
|
148
|
+
const info = lstatSync(path);
|
|
149
|
+
if (!info.isDirectory() || info.isSymbolicLink()) {
|
|
150
|
+
throw new Error(`unsafe_connect_state_directory:${path}`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if ((lstatSync(connect).mode & 0o077) !== 0) {
|
|
154
|
+
throw new Error('connect_state_directory_permissions_too_open');
|
|
155
|
+
}
|
|
156
|
+
return connect;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function assertPrivateFile(path: string): void {
|
|
160
|
+
const info = lstatSync(path);
|
|
161
|
+
if (!info.isFile() || info.isSymbolicLink()) throw new Error('unsafe_connect_state_file');
|
|
162
|
+
if ((info.mode & 0o077) !== 0) throw new Error('connect_state_permissions_too_open');
|
|
163
|
+
if (info.size < 2 || info.size > MAX_STATE_BYTES) throw new Error('invalid_connect_state_size');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const noFollowFlag = () => (typeof constants.O_NOFOLLOW === 'number' ? constants.O_NOFOLLOW : 0);
|
|
167
|
+
|
|
168
|
+
export function writeConnectState(state: ConnectState, appHome = ''): void {
|
|
169
|
+
const checked = validateConnectState(state);
|
|
170
|
+
const directory = prepareStateDirectory(appHome);
|
|
171
|
+
const destination = join(directory, 'device.json');
|
|
172
|
+
const temporary = join(directory, `.device-${process.pid}-${crypto.randomUUID()}.tmp`);
|
|
173
|
+
const fd = openSync(
|
|
174
|
+
temporary,
|
|
175
|
+
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | noFollowFlag(),
|
|
176
|
+
0o600,
|
|
177
|
+
);
|
|
178
|
+
try {
|
|
179
|
+
writeFileSync(fd, `${JSON.stringify(checked, null, 2)}\n`, { encoding: 'utf8' });
|
|
180
|
+
fsyncSync(fd);
|
|
181
|
+
} finally {
|
|
182
|
+
closeSync(fd);
|
|
183
|
+
}
|
|
184
|
+
renameSync(temporary, destination);
|
|
185
|
+
chmodSync(destination, 0o600);
|
|
186
|
+
try {
|
|
187
|
+
const directoryFd = openSync(directory, constants.O_RDONLY);
|
|
188
|
+
try {
|
|
189
|
+
fsyncSync(directoryFd);
|
|
190
|
+
} finally {
|
|
191
|
+
closeSync(directoryFd);
|
|
192
|
+
}
|
|
193
|
+
} catch {
|
|
194
|
+
// The state file is already durable; not every filesystem allows directory fsync.
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function readConnectState(appHome = ''): ConnectState | null {
|
|
199
|
+
const path = connectStatePath(appHome);
|
|
200
|
+
if (!existsSync(path)) return null;
|
|
201
|
+
assertSafeStateDirectory(appHome);
|
|
202
|
+
assertPrivateFile(path);
|
|
203
|
+
const fd = openSync(path, constants.O_RDONLY | noFollowFlag());
|
|
204
|
+
try {
|
|
205
|
+
const info = fstatSync(fd);
|
|
206
|
+
if (!info.isFile() || info.size > MAX_STATE_BYTES) {
|
|
207
|
+
throw new Error('invalid_connect_state_size');
|
|
208
|
+
}
|
|
209
|
+
const value = JSON.parse(readFileSync(fd, 'utf8')) as { protocol?: unknown };
|
|
210
|
+
if (value.protocol === PLAINTEXT_KEY_CONNECT_STATE_PROTOCOL) {
|
|
211
|
+
throw new Error('legacy_connect_state_contains_plaintext_keys');
|
|
212
|
+
}
|
|
213
|
+
if (value.protocol === UNVERSIONED_TRUST_CONNECT_STATE_PROTOCOL) {
|
|
214
|
+
throw new Error('connect_state_requires_versioned_trust_repairing');
|
|
215
|
+
}
|
|
216
|
+
return validateConnectState(value);
|
|
217
|
+
} catch (error) {
|
|
218
|
+
if (error instanceof SyntaxError) throw new Error('invalid_connect_state_json');
|
|
219
|
+
throw error;
|
|
220
|
+
} finally {
|
|
221
|
+
closeSync(fd);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function removePendingConnectState(appHome = ''): boolean {
|
|
226
|
+
const state = readConnectState(appHome);
|
|
227
|
+
if (!state || state.status !== 'pending') return false;
|
|
228
|
+
unlinkSync(connectStatePath(appHome));
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function secureWebOrigin(value: unknown): string {
|
|
233
|
+
let url: URL;
|
|
234
|
+
try {
|
|
235
|
+
url = new URL(String(value ?? ''));
|
|
236
|
+
} catch {
|
|
237
|
+
throw new Error('invalid_connect_service_url');
|
|
238
|
+
}
|
|
239
|
+
const local = ['127.0.0.1', 'localhost', '[::1]', '::1'].includes(url.hostname);
|
|
240
|
+
if (url.protocol !== 'https:' && !(local && url.protocol === 'http:')) {
|
|
241
|
+
throw new Error('connect_service_requires_https');
|
|
242
|
+
}
|
|
243
|
+
if (url.username || url.password || url.search || url.hash) {
|
|
244
|
+
throw new Error('invalid_connect_service_url');
|
|
245
|
+
}
|
|
246
|
+
if (url.pathname !== '/' && url.pathname !== '') {
|
|
247
|
+
throw new Error('connect_service_must_be_an_origin');
|
|
248
|
+
}
|
|
249
|
+
url.pathname = '/';
|
|
250
|
+
return url.toString().replace(/\/$/, '');
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export const normalizeConnectServiceUrl = (value: unknown) => secureWebOrigin(value);
|
|
254
|
+
|
|
255
|
+
export async function loadTransparencyProfile(
|
|
256
|
+
serviceUrl: string,
|
|
257
|
+
explicitPath = '',
|
|
258
|
+
persistedRoot: KeyTransparencyRootEnvelope | null = null,
|
|
259
|
+
): Promise<{ stored: StoredTransparencyProfile; runtime: TransparencyProfile }> {
|
|
260
|
+
const normalizedService = normalizeConnectServiceUrl(serviceUrl);
|
|
261
|
+
const requested =
|
|
262
|
+
explicitPath ||
|
|
263
|
+
process.env.AGENTS_CITY_CONNECT_TRUST_FILE ||
|
|
264
|
+
(!persistedRoot ? builtInRootChain(normalizedService) : '');
|
|
265
|
+
if (!requested && !persistedRoot) {
|
|
266
|
+
throw new Error(
|
|
267
|
+
'this service has no pinned root chain; pass --trust-file or AGENTS_CITY_CONNECT_TRUST_FILE',
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
let chain: KeyTransparencyRootChain;
|
|
271
|
+
if (requested) {
|
|
272
|
+
const path = resolve(requested);
|
|
273
|
+
const metadata = lstatSync(path);
|
|
274
|
+
if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size > MAX_STATE_BYTES) {
|
|
275
|
+
throw new Error('invalid_key_transparency_profile_file');
|
|
276
|
+
}
|
|
277
|
+
try {
|
|
278
|
+
chain = parseKeyTransparencyRootChain(JSON.parse(readFileSync(path, 'utf8')));
|
|
279
|
+
} catch {
|
|
280
|
+
throw new Error('invalid_key_transparency_profile_file');
|
|
281
|
+
}
|
|
282
|
+
} else {
|
|
283
|
+
chain = {
|
|
284
|
+
protocol: KEY_TRANSPARENCY_ROOT_CHAIN_PROTOCOL,
|
|
285
|
+
roots: [persistedRoot!],
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
const resolvedProfile = await resolveKeyTransparencyRootChain(chain, persistedRoot);
|
|
289
|
+
return transparencyResult(normalizedService, resolvedProfile.root, resolvedProfile.trust);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function transparencyResult(
|
|
293
|
+
serviceUrl: string,
|
|
294
|
+
root: KeyTransparencyRootEnvelope,
|
|
295
|
+
trust: KeyTransparencyTrust,
|
|
296
|
+
): { stored: StoredTransparencyProfile; runtime: TransparencyProfile } {
|
|
297
|
+
const normalized = normalizeConnectServiceUrl(serviceUrl);
|
|
298
|
+
if (root.signed.controlPlaneUrl !== normalized) {
|
|
299
|
+
throw new Error('key_transparency_origin_mismatch');
|
|
300
|
+
}
|
|
301
|
+
return {
|
|
302
|
+
stored: { root },
|
|
303
|
+
runtime: { controlPlaneUrl: normalized, trust },
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export async function resolveStoredTransparency(state: ConnectState): Promise<TransparencyProfile> {
|
|
308
|
+
const chain = {
|
|
309
|
+
protocol: KEY_TRANSPARENCY_ROOT_CHAIN_PROTOCOL,
|
|
310
|
+
roots: [state.keyTransparency.root],
|
|
311
|
+
} as const;
|
|
312
|
+
const resolvedProfile = await resolveKeyTransparencyRootChain(chain, state.keyTransparency.root);
|
|
313
|
+
const resolved = transparencyResult(
|
|
314
|
+
state.serviceUrl,
|
|
315
|
+
resolvedProfile.root,
|
|
316
|
+
resolvedProfile.trust,
|
|
317
|
+
);
|
|
318
|
+
if (
|
|
319
|
+
state.status === 'connected' &&
|
|
320
|
+
resolvedProfile.root.signed.relayUrl !== state.device.relayUrl
|
|
321
|
+
)
|
|
322
|
+
throw new Error('key_transparency_relay_mismatch');
|
|
323
|
+
return resolved.runtime;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function readRootChainResponse(response: Response): Promise<KeyTransparencyRootChain> {
|
|
327
|
+
const declared = Number(response.headers.get('content-length') ?? 0);
|
|
328
|
+
if (Number.isFinite(declared) && declared > MAX_ROOT_CHAIN_BYTES) {
|
|
329
|
+
throw new Error('key_transparency_root_chain_too_large');
|
|
330
|
+
}
|
|
331
|
+
if (!response.body) throw new Error('empty_key_transparency_root_chain');
|
|
332
|
+
const reader = response.body.getReader();
|
|
333
|
+
const decoder = new TextDecoder();
|
|
334
|
+
let total = 0;
|
|
335
|
+
let body = '';
|
|
336
|
+
while (true) {
|
|
337
|
+
const { done, value } = await reader.read();
|
|
338
|
+
if (done) break;
|
|
339
|
+
total += value.byteLength;
|
|
340
|
+
if (total > MAX_ROOT_CHAIN_BYTES) {
|
|
341
|
+
await reader.cancel();
|
|
342
|
+
throw new Error('key_transparency_root_chain_too_large');
|
|
343
|
+
}
|
|
344
|
+
body += decoder.decode(value, { stream: true });
|
|
345
|
+
}
|
|
346
|
+
body += decoder.decode();
|
|
347
|
+
try {
|
|
348
|
+
return parseKeyTransparencyRootChain(JSON.parse(body));
|
|
349
|
+
} catch {
|
|
350
|
+
throw new Error('invalid_key_transparency_root_chain_response');
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export async function refreshStoredTransparency(
|
|
355
|
+
appHome = '',
|
|
356
|
+
fetcher: typeof fetch = fetch,
|
|
357
|
+
): Promise<{
|
|
358
|
+
state: ConnectState;
|
|
359
|
+
runtime: TransparencyProfile;
|
|
360
|
+
updated: boolean;
|
|
361
|
+
refreshWarning: string | null;
|
|
362
|
+
}> {
|
|
363
|
+
const state = readConnectState(appHome);
|
|
364
|
+
if (!state) throw new Error('connect_state_missing');
|
|
365
|
+
let cached: TransparencyProfile | null = null;
|
|
366
|
+
let cachedError: unknown = null;
|
|
367
|
+
try {
|
|
368
|
+
cached = await resolveStoredTransparency(state);
|
|
369
|
+
} catch (error) {
|
|
370
|
+
cachedError = error;
|
|
371
|
+
}
|
|
372
|
+
const endpoint = new URL('/api/key-transparency/roots', state.serviceUrl);
|
|
373
|
+
endpoint.searchParams.set('from', String(state.keyTransparency.root.signed.version));
|
|
374
|
+
let response: Response;
|
|
375
|
+
try {
|
|
376
|
+
response = await fetcher(endpoint, {
|
|
377
|
+
headers: { accept: 'application/json' },
|
|
378
|
+
redirect: 'error',
|
|
379
|
+
signal: AbortSignal.timeout(5_000),
|
|
380
|
+
});
|
|
381
|
+
} catch {
|
|
382
|
+
if (!cached) throw cachedError;
|
|
383
|
+
return {
|
|
384
|
+
state,
|
|
385
|
+
runtime: cached,
|
|
386
|
+
updated: false,
|
|
387
|
+
refreshWarning: 'key_transparency_root_refresh_unavailable',
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
if (!response.ok) {
|
|
391
|
+
if (!cached) throw cachedError;
|
|
392
|
+
return {
|
|
393
|
+
state,
|
|
394
|
+
runtime: cached,
|
|
395
|
+
updated: false,
|
|
396
|
+
refreshWarning: `key_transparency_root_refresh_http_${response.status}`,
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const chain = await readRootChainResponse(response);
|
|
401
|
+
const latestState = readConnectState(appHome);
|
|
402
|
+
if (!latestState) throw new Error('connect_state_missing');
|
|
403
|
+
const resolvedProfile = await resolveKeyTransparencyRootChain(
|
|
404
|
+
chain,
|
|
405
|
+
latestState.keyTransparency.root,
|
|
406
|
+
);
|
|
407
|
+
const resolved = transparencyResult(
|
|
408
|
+
latestState.serviceUrl,
|
|
409
|
+
resolvedProfile.root,
|
|
410
|
+
resolvedProfile.trust,
|
|
411
|
+
);
|
|
412
|
+
if (
|
|
413
|
+
latestState.status === 'connected' &&
|
|
414
|
+
resolvedProfile.root.signed.relayUrl !== latestState.device.relayUrl
|
|
415
|
+
)
|
|
416
|
+
throw new Error('key_transparency_relay_mismatch');
|
|
417
|
+
|
|
418
|
+
const updated =
|
|
419
|
+
resolvedProfile.root.signed.version > latestState.keyTransparency.root.signed.version;
|
|
420
|
+
const nextState: ConnectState = updated
|
|
421
|
+
? {
|
|
422
|
+
...latestState,
|
|
423
|
+
...(latestState.status === 'connected' ? { updatedAt: new Date().toISOString() } : {}),
|
|
424
|
+
keyTransparency: resolved.stored,
|
|
425
|
+
}
|
|
426
|
+
: latestState;
|
|
427
|
+
if (updated) writeConnectState(nextState, appHome);
|
|
428
|
+
return {
|
|
429
|
+
state: nextState,
|
|
430
|
+
runtime: resolved.runtime,
|
|
431
|
+
updated,
|
|
432
|
+
refreshWarning: null,
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function secureRelayUrl(value: unknown): string {
|
|
437
|
+
let url: URL;
|
|
438
|
+
try {
|
|
439
|
+
url = new URL(String(value ?? ''));
|
|
440
|
+
} catch {
|
|
441
|
+
throw new Error('invalid_relay_url');
|
|
442
|
+
}
|
|
443
|
+
const local = ['127.0.0.1', 'localhost', '[::1]', '::1'].includes(url.hostname);
|
|
444
|
+
if (url.protocol !== 'wss:' && !(local && url.protocol === 'ws:')) {
|
|
445
|
+
throw new Error('relay_requires_wss');
|
|
446
|
+
}
|
|
447
|
+
if (url.username || url.password || url.search || url.hash || url.pathname !== '/v1/connect')
|
|
448
|
+
throw new Error('invalid_relay_url');
|
|
449
|
+
return url.toString();
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const decodedLength = (value: unknown) => {
|
|
453
|
+
if (typeof value !== 'string' || !/^[A-Za-z0-9_-]+$/.test(value)) return -1;
|
|
454
|
+
try {
|
|
455
|
+
return Buffer.from(value, 'base64url').byteLength;
|
|
456
|
+
} catch {
|
|
457
|
+
return -1;
|
|
458
|
+
}
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
function validateStoredTransparency(value: unknown, serviceUrl: string): StoredTransparencyProfile {
|
|
462
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
463
|
+
throw new Error('invalid_key_transparency_profile');
|
|
464
|
+
}
|
|
465
|
+
const profile = value as Record<string, unknown>;
|
|
466
|
+
if (Object.keys(profile).length !== 1 || !Object.hasOwn(profile, 'root')) {
|
|
467
|
+
throw new Error('invalid_key_transparency_profile');
|
|
468
|
+
}
|
|
469
|
+
const parsed = parseKeyTransparencyRootChain({
|
|
470
|
+
protocol: KEY_TRANSPARENCY_ROOT_CHAIN_PROTOCOL,
|
|
471
|
+
roots: [profile.root],
|
|
472
|
+
});
|
|
473
|
+
const root = parsed.roots[0]!;
|
|
474
|
+
if (root.signed.controlPlaneUrl !== serviceUrl) {
|
|
475
|
+
throw new Error('key_transparency_origin_mismatch');
|
|
476
|
+
}
|
|
477
|
+
return { root };
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function validateAuthorization(value: unknown, serviceUrl: string): DeviceAuthorization {
|
|
481
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
482
|
+
throw new Error('invalid_device_authorization');
|
|
483
|
+
}
|
|
484
|
+
const authorization = value as DeviceAuthorization;
|
|
485
|
+
if (
|
|
486
|
+
typeof authorization.device_code !== 'string' ||
|
|
487
|
+
!authorization.device_code.startsWith('pasco_') ||
|
|
488
|
+
typeof authorization.user_code !== 'string' ||
|
|
489
|
+
!/^PASCO-[A-Z0-9-]{8,20}$/.test(authorization.user_code) ||
|
|
490
|
+
!Number.isSafeInteger(authorization.expires_in) ||
|
|
491
|
+
authorization.expires_in < 30 ||
|
|
492
|
+
authorization.expires_in > 3_600 ||
|
|
493
|
+
!Number.isSafeInteger(authorization.interval) ||
|
|
494
|
+
authorization.interval < 1 ||
|
|
495
|
+
authorization.interval > 60 ||
|
|
496
|
+
typeof authorization.signing_key_thumbprint !== 'string' ||
|
|
497
|
+
decodedLength(authorization.signing_key_thumbprint) !== 32
|
|
498
|
+
)
|
|
499
|
+
throw new Error('invalid_device_authorization');
|
|
500
|
+
const verification = new URL(authorization.verification_uri);
|
|
501
|
+
if (verification.origin !== new URL(serviceUrl).origin) {
|
|
502
|
+
throw new Error('verification_origin_mismatch');
|
|
503
|
+
}
|
|
504
|
+
return { ...authorization };
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function validateAssignment(value: unknown): DeviceAssignment {
|
|
508
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
509
|
+
throw new Error('invalid_device_assignment');
|
|
510
|
+
}
|
|
511
|
+
const assignment = value as DeviceAssignment;
|
|
512
|
+
if (
|
|
513
|
+
!UUID_RE.test(String(assignment.deviceId ?? '')) ||
|
|
514
|
+
!OWNER_RE.test(String(assignment.ownerPrefix ?? '')) ||
|
|
515
|
+
!Number.isSafeInteger(assignment.keyVersion) ||
|
|
516
|
+
assignment.keyVersion < 1
|
|
517
|
+
)
|
|
518
|
+
throw new Error('invalid_device_assignment');
|
|
519
|
+
return {
|
|
520
|
+
deviceId: assignment.deviceId,
|
|
521
|
+
ownerPrefix: assignment.ownerPrefix,
|
|
522
|
+
relayUrl: secureRelayUrl(assignment.relayUrl),
|
|
523
|
+
keyVersion: assignment.keyVersion,
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function validateBinding(value: unknown): ConnectedCityBinding {
|
|
528
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
529
|
+
throw new Error('invalid_connected_city');
|
|
530
|
+
}
|
|
531
|
+
const city = value as ConnectedCityBinding;
|
|
532
|
+
const rawDataDir = String(city.dataDir ?? '');
|
|
533
|
+
const dataDir = resolve(rawDataDir);
|
|
534
|
+
if (
|
|
535
|
+
typeof city.localCityId !== 'string' ||
|
|
536
|
+
!/^[A-Za-z0-9_-]{4,160}$/.test(city.localCityId) ||
|
|
537
|
+
!OWNER_RE.test(String(city.slug ?? '')) ||
|
|
538
|
+
typeof city.name !== 'string' ||
|
|
539
|
+
!city.name.trim() ||
|
|
540
|
+
city.name.length > 100 ||
|
|
541
|
+
!CITY_ADDRESS_RE.test(String(city.remoteAddress ?? '')) ||
|
|
542
|
+
decodedLength(city.encryptionKeyId) !== 32 ||
|
|
543
|
+
typeof city.connected !== 'boolean' ||
|
|
544
|
+
!rawDataDir.startsWith('/') ||
|
|
545
|
+
!dataDir.startsWith('/')
|
|
546
|
+
)
|
|
547
|
+
throw new Error('invalid_connected_city');
|
|
548
|
+
return { ...city, dataDir };
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
export function validateConnectState(value: unknown): ConnectState {
|
|
552
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
553
|
+
throw new Error('invalid_connect_state');
|
|
554
|
+
}
|
|
555
|
+
const state = value as Partial<ConnectState> & Record<string, unknown>;
|
|
556
|
+
if (state.protocol !== CONNECT_STATE_PROTOCOL) throw new Error('invalid_connect_state_protocol');
|
|
557
|
+
const serviceUrl = normalizeConnectServiceUrl(state.serviceUrl);
|
|
558
|
+
const keyTransparency = validateStoredTransparency(state.keyTransparency, serviceUrl);
|
|
559
|
+
if (state.status === 'pending') {
|
|
560
|
+
if (
|
|
561
|
+
typeof state.machineName !== 'string' ||
|
|
562
|
+
!state.machineName.trim() ||
|
|
563
|
+
state.machineName.length > 100 ||
|
|
564
|
+
typeof state.createdAt !== 'string' ||
|
|
565
|
+
!Number.isFinite(Date.parse(state.createdAt))
|
|
566
|
+
)
|
|
567
|
+
throw new Error('invalid_connect_state');
|
|
568
|
+
return {
|
|
569
|
+
protocol: CONNECT_STATE_PROTOCOL,
|
|
570
|
+
status: 'pending',
|
|
571
|
+
serviceUrl,
|
|
572
|
+
machineName: state.machineName,
|
|
573
|
+
createdAt: state.createdAt,
|
|
574
|
+
authorization: validateAuthorization(state.authorization, serviceUrl),
|
|
575
|
+
keyTransparency,
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
if (
|
|
579
|
+
state.status !== 'connected' ||
|
|
580
|
+
typeof state.connectedAt !== 'string' ||
|
|
581
|
+
!Number.isFinite(Date.parse(state.connectedAt)) ||
|
|
582
|
+
typeof state.updatedAt !== 'string' ||
|
|
583
|
+
!Number.isFinite(Date.parse(state.updatedAt)) ||
|
|
584
|
+
!Array.isArray(state.cities) ||
|
|
585
|
+
state.cities.length > 100
|
|
586
|
+
)
|
|
587
|
+
throw new Error('invalid_connect_state');
|
|
588
|
+
const cities = state.cities.map(validateBinding);
|
|
589
|
+
if (
|
|
590
|
+
new Set(cities.map((city) => city.localCityId)).size !== cities.length ||
|
|
591
|
+
new Set(cities.map((city) => city.remoteAddress)).size !== cities.length
|
|
592
|
+
)
|
|
593
|
+
throw new Error('duplicate_connected_city');
|
|
594
|
+
const device = validateAssignment(state.device);
|
|
595
|
+
if (keyTransparency.root.signed.relayUrl !== device.relayUrl) {
|
|
596
|
+
throw new Error('key_transparency_relay_mismatch');
|
|
597
|
+
}
|
|
598
|
+
return {
|
|
599
|
+
protocol: CONNECT_STATE_PROTOCOL,
|
|
600
|
+
status: 'connected',
|
|
601
|
+
serviceUrl,
|
|
602
|
+
connectedAt: state.connectedAt,
|
|
603
|
+
updatedAt: state.updatedAt,
|
|
604
|
+
device,
|
|
605
|
+
keyTransparency,
|
|
606
|
+
cities,
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
export async function openConnectDeviceVault(appHome = ''): Promise<NodeDeviceVault> {
|
|
611
|
+
prepareStateDirectory(appHome);
|
|
612
|
+
await initializeHybridCrypto();
|
|
613
|
+
return createOsProtectedDeviceVault({
|
|
614
|
+
directory: connectVaultDirectory(appHome),
|
|
615
|
+
service: 'agents-city-private-device',
|
|
616
|
+
account: vaultAccount(appHome),
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
export async function loadOrCreateConnectKeys(appHome = ''): Promise<{
|
|
621
|
+
vault: NodeDeviceVault;
|
|
622
|
+
keys: DeviceKeys;
|
|
623
|
+
}> {
|
|
624
|
+
const vault = await openConnectDeviceVault(appHome);
|
|
625
|
+
return { vault, keys: await vault.loadOrCreateKeys() };
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
export async function loadConnectIdentity(
|
|
629
|
+
state: ConnectedConnectState,
|
|
630
|
+
appHome = '',
|
|
631
|
+
): Promise<DeviceIdentity> {
|
|
632
|
+
const vault = await openConnectDeviceVault(appHome);
|
|
633
|
+
const identity = await vault.loadIdentity();
|
|
634
|
+
if (!identity) throw new Error('connect_device_identity_missing');
|
|
635
|
+
if (
|
|
636
|
+
identity.deviceId !== state.device.deviceId ||
|
|
637
|
+
identity.ownerPrefix !== state.device.ownerPrefix ||
|
|
638
|
+
identity.relayUrl !== state.device.relayUrl ||
|
|
639
|
+
identity.keyVersion !== state.device.keyVersion
|
|
640
|
+
)
|
|
641
|
+
throw new Error('connect_device_identity_mismatch');
|
|
642
|
+
return identity;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
export function connectedStateForCity(
|
|
646
|
+
localCityId: string,
|
|
647
|
+
appHome = '',
|
|
648
|
+
): { state: ConnectedConnectState; binding: ConnectedCityBinding } | null {
|
|
649
|
+
const state = readConnectState(appHome);
|
|
650
|
+
if (!state || state.status !== 'connected') return null;
|
|
651
|
+
const binding = state.cities.find((city) => city.localCityId === localCityId && city.connected);
|
|
652
|
+
return binding ? { state, binding } : null;
|
|
653
|
+
}
|