@lazyingart/agent-web 0.1.40

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 (42) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +438 -0
  3. package/docs/architecture.md +503 -0
  4. package/package.json +43 -0
  5. package/src/aginti-adapter.js +602 -0
  6. package/src/chat-context.js +1020 -0
  7. package/src/chat-migrations.js +947 -0
  8. package/src/chat-store.js +3308 -0
  9. package/src/cli.js +134 -0
  10. package/src/cloud-server.js +2043 -0
  11. package/src/contracts.js +103 -0
  12. package/src/deterministic-context-summarizer.js +254 -0
  13. package/src/direct-chat-capability-limits.js +66 -0
  14. package/src/direct-chat-contract.js +3 -0
  15. package/src/errors.js +50 -0
  16. package/src/http-contract.js +592 -0
  17. package/src/index.js +88 -0
  18. package/src/localllm-connector.js +667 -0
  19. package/src/migrations.js +231 -0
  20. package/src/operator-health.js +184 -0
  21. package/src/password-verifier.js +131 -0
  22. package/src/service-config.js +547 -0
  23. package/src/service.js +408 -0
  24. package/src/sqlite-health.js +83 -0
  25. package/src/storage-path.js +130 -0
  26. package/src/store.js +914 -0
  27. package/src/validation.js +181 -0
  28. package/src/vision-attachment.js +404 -0
  29. package/src/web/aginti-client.js +552 -0
  30. package/src/web/aginti-protocol.js +1146 -0
  31. package/src/web/asset-map.js +462 -0
  32. package/src/web/browser-app.js +6491 -0
  33. package/src/web/cloud-session-client.js +427 -0
  34. package/src/web/direct-chat-client.js +1482 -0
  35. package/src/web/index.js +10 -0
  36. package/src/web/presentation-state.js +107 -0
  37. package/src/web/pwa-assets.js +854 -0
  38. package/src/web/pwa-update-handoff-store.js +179 -0
  39. package/src/web/safe-rendering.js +836 -0
  40. package/src/web/vision-image-client.js +546 -0
  41. package/src/web/vision-image-sanitizer.js +168 -0
  42. package/src/web/web-release.js +28 -0
@@ -0,0 +1,547 @@
1
+ import {
2
+ closeSync,
3
+ constants,
4
+ fstatSync,
5
+ lstatSync,
6
+ openSync,
7
+ readFileSync,
8
+ realpathSync
9
+ } from 'node:fs';
10
+ import { isAbsolute, join, resolve } from 'node:path';
11
+ import { TextDecoder } from 'node:util';
12
+
13
+ import { VISION_MODEL_ALIAS } from './vision-attachment.js';
14
+
15
+ const SERVICE_SCHEMA = 'lazying-agent-service/v1';
16
+ const CREDENTIAL_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/u;
17
+ const SYSTEMD_UNIT_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.@-]{0,127}$/u;
18
+ const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u;
19
+ const MODEL_ALIAS_PATTERN = /^localllm-[a-z0-9]+(?:-[a-z0-9]+)*$/u;
20
+ const VERSION_LABEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._~-]{0,23}$/u;
21
+ const loadedConfigBrand = new WeakSet();
22
+ const utf8 = new TextDecoder('utf-8', { fatal: true });
23
+
24
+ function currentUid() {
25
+ return typeof process.getuid === 'function' ? process.getuid() : null;
26
+ }
27
+
28
+ export function isTrustedCredentialOwner(ownerUid, serviceUid = currentUid(), ownerGid = ownerUid) {
29
+ if (!Number.isSafeInteger(ownerUid) || ownerUid < 0
30
+ || !Number.isSafeInteger(ownerGid) || ownerGid < 0
31
+ || (serviceUid !== null && (!Number.isSafeInteger(serviceUid) || serviceUid < 0))) {
32
+ throw new TypeError('credential owner identifiers are invalid');
33
+ }
34
+ if (ownerUid === 0) return ownerGid === 0;
35
+ return serviceUid === null || ownerUid === serviceUid;
36
+ }
37
+
38
+ export function isSystemdCredentialPath(pathname, { directory = false } = {}) {
39
+ if (typeof pathname !== 'string' || typeof directory !== 'boolean'
40
+ || !isAbsolute(pathname) || resolve(pathname) !== pathname) {
41
+ return false;
42
+ }
43
+ const prefix = '/run/credentials/';
44
+ if (!pathname.startsWith(prefix)) return false;
45
+ const parts = pathname.slice(prefix.length).split('/');
46
+ if ((directory && parts.length !== 1) || (!directory && parts.length !== 2)
47
+ || !SYSTEMD_UNIT_NAME_PATTERN.test(parts[0])) {
48
+ return false;
49
+ }
50
+ return directory || CREDENTIAL_NAME_PATTERN.test(parts[1]);
51
+ }
52
+
53
+ export function isTrustedCredentialMode(mode, { rootOwned = false, directory = false } = {}) {
54
+ if (!Number.isSafeInteger(mode) || mode < 0 || typeof rootOwned !== 'boolean'
55
+ || typeof directory !== 'boolean') {
56
+ throw new TypeError('credential mode metadata is invalid');
57
+ }
58
+ const permissions = mode & 0o777;
59
+ if (!rootOwned) return (permissions & 0o077) === 0;
60
+ if (directory) {
61
+ return (permissions & 0o500) === 0o500 && (permissions & 0o027) === 0;
62
+ }
63
+ return (permissions & 0o400) === 0o400 && (permissions & 0o137) === 0;
64
+ }
65
+
66
+ function assertOwnerOnly(stat, label, { allowRootCredentialOwner = false } = {}) {
67
+ const uid = currentUid();
68
+ const trustedOwner = allowRootCredentialOwner
69
+ ? isTrustedCredentialOwner(stat.uid, uid, stat.gid)
70
+ : uid === null || stat.uid === uid;
71
+ if (!trustedOwner) {
72
+ throw new TypeError(`${label} must be owned by the service user${allowRootCredentialOwner ? ' or root credential authority' : ''}`);
73
+ }
74
+ const rootCredential = allowRootCredentialOwner && stat.uid === 0;
75
+ if (!isTrustedCredentialMode(stat.mode, {
76
+ rootOwned: rootCredential,
77
+ directory: stat.isDirectory()
78
+ })) {
79
+ throw new TypeError(`${label} must be owner-only or a read-only root-owned systemd credential`);
80
+ }
81
+ }
82
+
83
+ function secureDirectory(pathname, label, options) {
84
+ if (typeof pathname !== 'string' || !isAbsolute(pathname) || resolve(pathname) !== pathname) {
85
+ throw new TypeError(`${label} must be an absolute normalized path`);
86
+ }
87
+ const stat = lstatSync(pathname);
88
+ if (!stat.isDirectory() || stat.isSymbolicLink() || realpathSync(pathname) !== pathname) {
89
+ throw new TypeError(`${label} must be a real directory without symlink indirection`);
90
+ }
91
+ if (options?.allowRootCredentialOwner && stat.uid === 0
92
+ && !isSystemdCredentialPath(pathname, { directory: true })) {
93
+ throw new TypeError(`${label} root-owned systemd credentials must be under /run/credentials/<unit>`);
94
+ }
95
+ assertOwnerOnly(stat, label, options);
96
+ return pathname;
97
+ }
98
+
99
+ function secureRegularFile(pathname, label, maximumBytes, options) {
100
+ if (typeof pathname !== 'string' || !isAbsolute(pathname) || resolve(pathname) !== pathname) {
101
+ throw new TypeError(`${label} must be an absolute normalized path`);
102
+ }
103
+ const before = lstatSync(pathname);
104
+ if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1
105
+ || before.size < 1 || before.size > maximumBytes || realpathSync(pathname) !== pathname) {
106
+ throw new TypeError(`${label} must be one bounded regular file without links`);
107
+ }
108
+ if (options?.allowRootCredentialOwner && before.uid === 0
109
+ && !isSystemdCredentialPath(pathname)) {
110
+ throw new TypeError(`${label} root-owned systemd credentials must be under /run/credentials/<unit>`);
111
+ }
112
+ assertOwnerOnly(before, label, options);
113
+ const descriptor = openSync(pathname, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
114
+ try {
115
+ const opened = fstatSync(descriptor);
116
+ if (!opened.isFile() || opened.nlink !== 1 || opened.dev !== before.dev || opened.ino !== before.ino
117
+ || opened.size !== before.size || opened.size > maximumBytes) {
118
+ throw new TypeError(`${label} changed while it was being opened`);
119
+ }
120
+ assertOwnerOnly(opened, label, options);
121
+ const bytes = readFileSync(descriptor);
122
+ const after = fstatSync(descriptor);
123
+ if (after.dev !== opened.dev || after.ino !== opened.ino || after.size !== opened.size
124
+ || bytes.byteLength !== opened.size) {
125
+ throw new TypeError(`${label} changed while it was being read`);
126
+ }
127
+ return bytes;
128
+ } finally {
129
+ closeSync(descriptor);
130
+ }
131
+ }
132
+
133
+ function decodedText(bytes, label, { trailingNewline = false } = {}) {
134
+ let value;
135
+ try {
136
+ value = utf8.decode(bytes);
137
+ } catch (error) {
138
+ throw new TypeError(`${label} must contain canonical UTF-8 text`, { cause: error });
139
+ }
140
+ if (trailingNewline && value.endsWith('\n')) value = value.slice(0, -1);
141
+ if (!value || value.includes('\u0000')) throw new TypeError(`${label} is empty or contains NUL bytes`);
142
+ return value;
143
+ }
144
+
145
+ function rejectDuplicateJsonKeys(source) {
146
+ let index = 0;
147
+
148
+ function whitespace() {
149
+ while (index < source.length && /[\u0009\u000a\u000d\u0020]/u.test(source[index])) index += 1;
150
+ }
151
+
152
+ function stringToken() {
153
+ if (source[index] !== '"') throw new SyntaxError('expected JSON string');
154
+ const start = index;
155
+ index += 1;
156
+ while (index < source.length) {
157
+ const character = source[index];
158
+ if (character === '"') {
159
+ index += 1;
160
+ return JSON.parse(source.slice(start, index));
161
+ }
162
+ if (character === '\\') {
163
+ index += 2;
164
+ } else {
165
+ index += 1;
166
+ }
167
+ }
168
+ throw new SyntaxError('unterminated JSON string');
169
+ }
170
+
171
+ function value(depth) {
172
+ if (depth > 64) throw new SyntaxError('JSON nesting is too deep');
173
+ whitespace();
174
+ if (source[index] === '{') {
175
+ index += 1;
176
+ whitespace();
177
+ const keys = new Set();
178
+ if (source[index] === '}') {
179
+ index += 1;
180
+ return;
181
+ }
182
+ while (true) {
183
+ whitespace();
184
+ const key = stringToken();
185
+ if (keys.has(key)) throw new SyntaxError(`duplicate JSON key ${JSON.stringify(key)}`);
186
+ keys.add(key);
187
+ whitespace();
188
+ if (source[index] !== ':') throw new SyntaxError('expected JSON colon');
189
+ index += 1;
190
+ value(depth + 1);
191
+ whitespace();
192
+ if (source[index] === '}') {
193
+ index += 1;
194
+ return;
195
+ }
196
+ if (source[index] !== ',') throw new SyntaxError('expected JSON object separator');
197
+ index += 1;
198
+ }
199
+ }
200
+ if (source[index] === '[') {
201
+ index += 1;
202
+ whitespace();
203
+ if (source[index] === ']') {
204
+ index += 1;
205
+ return;
206
+ }
207
+ while (true) {
208
+ value(depth + 1);
209
+ whitespace();
210
+ if (source[index] === ']') {
211
+ index += 1;
212
+ return;
213
+ }
214
+ if (source[index] !== ',') throw new SyntaxError('expected JSON array separator');
215
+ index += 1;
216
+ }
217
+ }
218
+ if (source[index] === '"') {
219
+ stringToken();
220
+ return;
221
+ }
222
+ const start = index;
223
+ while (index < source.length && !/[\u0009\u000a\u000d\u0020,\]}]/u.test(source[index])) index += 1;
224
+ if (start === index) throw new SyntaxError('expected JSON value');
225
+ JSON.parse(source.slice(start, index));
226
+ }
227
+
228
+ whitespace();
229
+ value(0);
230
+ whitespace();
231
+ if (index !== source.length) throw new SyntaxError('unexpected trailing JSON data');
232
+ }
233
+
234
+ function plainObject(value, required, optional, label) {
235
+ if (value === null || typeof value !== 'object' || Array.isArray(value)
236
+ || Object.getPrototypeOf(value) !== Object.prototype) {
237
+ throw new TypeError(`${label} must be a plain object`);
238
+ }
239
+ const allowed = new Set([...required, ...optional]);
240
+ const descriptors = Object.getOwnPropertyDescriptors(value);
241
+ for (const key of Reflect.ownKeys(descriptors)) {
242
+ const descriptor = descriptors[key];
243
+ if (typeof key !== 'string' || !allowed.has(key) || !descriptor.enumerable
244
+ || !Object.hasOwn(descriptor, 'value')) {
245
+ throw new TypeError(`${label} contains an unsupported field or accessor`);
246
+ }
247
+ }
248
+ for (const key of required) {
249
+ if (!Object.hasOwn(descriptors, key)) throw new TypeError(`${label}.${key} is required`);
250
+ }
251
+ return value;
252
+ }
253
+
254
+ function boundedText(value, name, { minimum = 1, maximum, pattern, controls = false } = {}) {
255
+ if (typeof value !== 'string' || value.length < minimum || value.length > maximum
256
+ || (!controls && /[\u0000-\u001f\u007f]/u.test(value))
257
+ || (pattern && !pattern.test(value))) {
258
+ throw new TypeError(`${name} is invalid`);
259
+ }
260
+ return value;
261
+ }
262
+
263
+ function identifier(value, name) {
264
+ return boundedText(value, name, { maximum: 128, pattern: IDENTIFIER_PATTERN });
265
+ }
266
+
267
+ function absoluteDatabasePath(value, name) {
268
+ if (typeof value !== 'string' || !isAbsolute(value) || resolve(value) !== value
269
+ || value === '/' || value.includes('\u0000')) {
270
+ throw new TypeError(`${name} must be an absolute normalized database file path`);
271
+ }
272
+ return value;
273
+ }
274
+
275
+ function publicOrigin(value) {
276
+ if (typeof value !== 'string') throw new TypeError('publicOrigin is invalid');
277
+ const url = new URL(value);
278
+ if (url.protocol !== 'https:' || url.username || url.password || url.pathname !== '/'
279
+ || url.search || url.hash || url.origin !== value) {
280
+ throw new TypeError('publicOrigin must be an exact HTTPS origin');
281
+ }
282
+ return value;
283
+ }
284
+
285
+ function localLlmBaseUrl(value) {
286
+ if (typeof value !== 'string') throw new TypeError('localLlm.baseUrl is invalid');
287
+ const url = new URL(value);
288
+ if (url.protocol !== 'http:' || url.hostname !== '127.0.0.1'
289
+ || !/^[1-9]\d{3,4}$/u.test(url.port) || Number(url.port) < 1_024
290
+ || Number(url.port) > 65_535 || url.pathname !== '/v1'
291
+ || url.username || url.password || url.search || url.hash
292
+ || url.toString().replace(/\/$/u, '') !== value) {
293
+ throw new TypeError('localLlm.baseUrl must be an exact private 127.0.0.1 HTTP /v1 endpoint');
294
+ }
295
+ return value;
296
+ }
297
+
298
+ function agintiBaseUrl(value) {
299
+ if (typeof value !== 'string') throw new TypeError('aginti.baseUrl is invalid');
300
+ const url = new URL(value);
301
+ if (url.protocol !== 'http:' || url.hostname !== '127.0.0.1'
302
+ || !/^[1-9]\d{3,4}$/u.test(url.port) || Number(url.port) < 1_024
303
+ || Number(url.port) > 65_535 || url.pathname !== '/'
304
+ || url.username || url.password || url.search || url.hash
305
+ || url.origin !== value) {
306
+ throw new TypeError('aginti.baseUrl must be an exact private 127.0.0.1 HTTP origin');
307
+ }
308
+ return value;
309
+ }
310
+
311
+ function modelAliases(value) {
312
+ if (!Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype
313
+ || value.length < 1 || value.length > 32) {
314
+ throw new TypeError('localLlm.allowedModelAliases must be a bounded array');
315
+ }
316
+ const descriptors = Object.getOwnPropertyDescriptors(value);
317
+ for (let index = 0; index < value.length; index += 1) {
318
+ const descriptor = descriptors[String(index)];
319
+ if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value')) {
320
+ throw new TypeError('localLlm.allowedModelAliases must be a dense data array');
321
+ }
322
+ }
323
+ if (Reflect.ownKeys(descriptors).some((key) => key !== 'length'
324
+ && (typeof key !== 'string' || !/^(0|[1-9]\d*)$/u.test(key)
325
+ || Number(key) >= value.length))) {
326
+ throw new TypeError('localLlm.allowedModelAliases contains an unsupported property');
327
+ }
328
+ const seen = new Set();
329
+ return Object.freeze(value.map((alias) => {
330
+ boundedText(alias, 'localLlm model alias', { maximum: 64, pattern: MODEL_ALIAS_PATTERN });
331
+ if (seen.has(alias)) throw new TypeError('localLlm model aliases must be unique');
332
+ seen.add(alias);
333
+ return alias;
334
+ }));
335
+ }
336
+
337
+ function credentialName(value, name) {
338
+ return boundedText(value, name, { maximum: 64, pattern: CREDENTIAL_NAME_PATTERN });
339
+ }
340
+
341
+ function deepFreeze(value) {
342
+ if (value && typeof value === 'object' && !Object.isFrozen(value)) {
343
+ for (const child of Object.values(value)) deepFreeze(child);
344
+ Object.freeze(value);
345
+ }
346
+ return value;
347
+ }
348
+
349
+ function validateConfig(value) {
350
+ const root = plainObject(
351
+ value,
352
+ ['schema', 'listen', 'publicOrigin', 'account', 'state', 'pwa', 'localLlm', 'aginti', 'credentials'],
353
+ [],
354
+ 'service config'
355
+ );
356
+ if (root.schema !== SERVICE_SCHEMA) throw new TypeError('service config schema is unsupported');
357
+
358
+ const listen = plainObject(root.listen, ['host', 'port'], [], 'listen');
359
+ if (listen.host !== '127.0.0.1' || !Number.isSafeInteger(listen.port)
360
+ || listen.port < 1_024 || listen.port > 65_535) {
361
+ throw new TypeError('listen must be an exact unprivileged 127.0.0.1 endpoint');
362
+ }
363
+
364
+ const account = plainObject(
365
+ root.account,
366
+ ['username', 'principalId', 'displayName'],
367
+ [],
368
+ 'account'
369
+ );
370
+ const username = boundedText(account.username, 'account.username', { maximum: 128 });
371
+ if (/[<>]/u.test(username)) throw new TypeError('account.username is invalid');
372
+ const principalId = identifier(account.principalId, 'account.principalId');
373
+ if (!/^[A-Za-z0-9_-]{16,128}$/u.test(principalId)) {
374
+ throw new TypeError('account.principalId must be an opaque 16-128 character principal');
375
+ }
376
+
377
+ const state = plainObject(
378
+ root.state,
379
+ ['cloudIndexDatabase', 'directChatDatabase'],
380
+ [],
381
+ 'state'
382
+ );
383
+ const cloudIndexDatabase = absoluteDatabasePath(state.cloudIndexDatabase, 'state.cloudIndexDatabase');
384
+ const directChatDatabase = absoluteDatabasePath(state.directChatDatabase, 'state.directChatDatabase');
385
+ if (cloudIndexDatabase === directChatDatabase) {
386
+ throw new TypeError('Cloud Index and Direct Chat require separate database paths');
387
+ }
388
+
389
+ const pwa = plainObject(
390
+ root.pwa,
391
+ ['versionLabel', 'title', 'name', 'shortName'],
392
+ [],
393
+ 'pwa'
394
+ );
395
+ const localLlm = plainObject(
396
+ root.localLlm,
397
+ ['baseUrl', 'allowedModelAliases', 'defaultModelAlias'],
398
+ ['vision'],
399
+ 'localLlm'
400
+ );
401
+ const aliases = modelAliases(localLlm.allowedModelAliases);
402
+ const defaultModelAlias = boundedText(localLlm.defaultModelAlias, 'localLlm.defaultModelAlias', {
403
+ maximum: 64,
404
+ pattern: MODEL_ALIAS_PATTERN
405
+ });
406
+ if (!aliases.includes(defaultModelAlias)) {
407
+ throw new TypeError('localLlm.defaultModelAlias must be in allowedModelAliases');
408
+ }
409
+ const visionInput = localLlm.vision ?? { enabled: false };
410
+ const vision = plainObject(visionInput, ['enabled'], [], 'localLlm.vision');
411
+ if (typeof vision.enabled !== 'boolean') throw new TypeError('localLlm.vision.enabled must be boolean');
412
+ if (vision.enabled && !aliases.includes(VISION_MODEL_ALIAS)) {
413
+ throw new TypeError(`localLlm.allowedModelAliases must include ${VISION_MODEL_ALIAS} when vision is enabled`);
414
+ }
415
+ if (vision.enabled && defaultModelAlias === VISION_MODEL_ALIAS) {
416
+ throw new TypeError('localLlm.defaultModelAlias must remain the text alias when vision is enabled');
417
+ }
418
+
419
+ const aginti = plainObject(root.aginti, ['enabled'], ['baseUrl'], 'aginti');
420
+ if (typeof aginti.enabled !== 'boolean') throw new TypeError('aginti.enabled must be boolean');
421
+ if (aginti.enabled !== Object.hasOwn(aginti, 'baseUrl')) {
422
+ throw new TypeError('aginti.baseUrl is required exactly when AgInTi is enabled');
423
+ }
424
+
425
+ const credentials = plainObject(
426
+ root.credentials,
427
+ ['passwordHash', 'localLlmToken'],
428
+ ['agintiToken'],
429
+ 'credentials'
430
+ );
431
+ const passwordHash = credentialName(credentials.passwordHash, 'credentials.passwordHash');
432
+ const localLlmToken = credentialName(credentials.localLlmToken, 'credentials.localLlmToken');
433
+ const agintiToken = credentials.agintiToken === undefined
434
+ ? undefined
435
+ : credentialName(credentials.agintiToken, 'credentials.agintiToken');
436
+ if (aginti.enabled !== (agintiToken !== undefined)) {
437
+ throw new TypeError('credentials.agintiToken is required exactly when AgInTi is enabled');
438
+ }
439
+ if (new Set([passwordHash, localLlmToken, agintiToken].filter(Boolean)).size
440
+ !== [passwordHash, localLlmToken, agintiToken].filter(Boolean).length) {
441
+ throw new TypeError('credential purposes must use separate files');
442
+ }
443
+
444
+ return deepFreeze({
445
+ schema: SERVICE_SCHEMA,
446
+ listen: { host: '127.0.0.1', port: listen.port },
447
+ publicOrigin: publicOrigin(root.publicOrigin),
448
+ account: {
449
+ username,
450
+ principalId,
451
+ displayName: boundedText(account.displayName, 'account.displayName', { maximum: 256 })
452
+ },
453
+ state: { cloudIndexDatabase, directChatDatabase },
454
+ pwa: {
455
+ versionLabel: boundedText(pwa.versionLabel, 'pwa.versionLabel', {
456
+ maximum: 24,
457
+ pattern: VERSION_LABEL_PATTERN
458
+ }),
459
+ title: boundedText(pwa.title, 'pwa.title', { maximum: 80 }),
460
+ name: boundedText(pwa.name, 'pwa.name', { maximum: 80 }),
461
+ shortName: boundedText(pwa.shortName, 'pwa.shortName', { maximum: 24 })
462
+ },
463
+ localLlm: {
464
+ baseUrl: localLlmBaseUrl(localLlm.baseUrl),
465
+ allowedModelAliases: aliases,
466
+ defaultModelAlias,
467
+ vision: { enabled: vision.enabled, modelAlias: VISION_MODEL_ALIAS }
468
+ },
469
+ aginti: {
470
+ enabled: aginti.enabled,
471
+ ...(aginti.enabled ? { baseUrl: agintiBaseUrl(aginti.baseUrl) } : {})
472
+ },
473
+ credentials: { passwordHash, localLlmToken, ...(agintiToken === undefined ? {} : { agintiToken }) }
474
+ });
475
+ }
476
+
477
+ class LoadedServiceConfig {
478
+ #credentialsDirectory;
479
+
480
+ constructor(config, credentialsDirectory) {
481
+ this.config = config;
482
+ this.#credentialsDirectory = credentialsDirectory;
483
+ loadedConfigBrand.add(this);
484
+ Object.freeze(this);
485
+ }
486
+
487
+ readCredential(purpose) {
488
+ if (purpose !== 'passwordHash' && purpose !== 'localLlmToken' && purpose !== 'agintiToken') {
489
+ throw new TypeError('credential purpose is unsupported');
490
+ }
491
+ if (!Object.hasOwn(this.config.credentials, purpose)) throw new TypeError('credential purpose is not configured');
492
+ const name = this.config.credentials[purpose];
493
+ const pathname = join(this.#credentialsDirectory, name);
494
+ if (resolve(pathname) !== pathname) throw new TypeError('credential name escaped its directory');
495
+ const maximum = purpose === 'passwordHash' ? 1_024 : 4_096;
496
+ const bytes = secureRegularFile(pathname, `${purpose} credential`, maximum + 1, {
497
+ allowRootCredentialOwner: true
498
+ });
499
+ try {
500
+ return decodedText(bytes, `${purpose} credential`, { trailingNewline: true });
501
+ } finally {
502
+ bytes.fill(0);
503
+ }
504
+ }
505
+
506
+ createCredentialProvider(purpose) {
507
+ if (purpose !== 'localLlmToken' && purpose !== 'agintiToken') {
508
+ throw new TypeError('only transport tokens support rotating credential providers');
509
+ }
510
+ if (!Object.hasOwn(this.config.credentials, purpose)) throw new TypeError('credential purpose is not configured');
511
+ const provider = async () => this.readCredential(purpose);
512
+ return Object.freeze(provider);
513
+ }
514
+ }
515
+
516
+ Object.freeze(LoadedServiceConfig.prototype);
517
+
518
+ export function loadServiceConfig({ configPath, credentialsDirectory } = {}) {
519
+ if (credentialsDirectory === undefined || credentialsDirectory === null || credentialsDirectory === '') {
520
+ throw new TypeError('credentialsDirectory is required (normally CREDENTIALS_DIRECTORY)');
521
+ }
522
+ const directory = secureDirectory(credentialsDirectory, 'credentialsDirectory', {
523
+ allowRootCredentialOwner: true
524
+ });
525
+ const bytes = secureRegularFile(configPath, 'service config', 64 * 1024);
526
+ let parsed;
527
+ try {
528
+ const text = decodedText(bytes, 'service config');
529
+ rejectDuplicateJsonKeys(text);
530
+ parsed = JSON.parse(text);
531
+ } catch (error) {
532
+ if (error instanceof SyntaxError) throw new TypeError('service config is not valid JSON', { cause: error });
533
+ throw error;
534
+ } finally {
535
+ bytes.fill(0);
536
+ }
537
+ return new LoadedServiceConfig(validateConfig(parsed), directory);
538
+ }
539
+
540
+ export function assertLoadedServiceConfig(value) {
541
+ if (!(value instanceof LoadedServiceConfig) || !loadedConfigBrand.has(value)) {
542
+ throw new TypeError('loadedConfig must come from loadServiceConfig()');
543
+ }
544
+ return value;
545
+ }
546
+
547
+ export const STANDALONE_SERVICE_CONFIG_SCHEMA = SERVICE_SCHEMA;