@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.
- package/LICENSE +22 -0
- package/README.md +438 -0
- package/docs/architecture.md +503 -0
- package/package.json +43 -0
- package/src/aginti-adapter.js +602 -0
- package/src/chat-context.js +1020 -0
- package/src/chat-migrations.js +947 -0
- package/src/chat-store.js +3308 -0
- package/src/cli.js +134 -0
- package/src/cloud-server.js +2043 -0
- package/src/contracts.js +103 -0
- package/src/deterministic-context-summarizer.js +254 -0
- package/src/direct-chat-capability-limits.js +66 -0
- package/src/direct-chat-contract.js +3 -0
- package/src/errors.js +50 -0
- package/src/http-contract.js +592 -0
- package/src/index.js +88 -0
- package/src/localllm-connector.js +667 -0
- package/src/migrations.js +231 -0
- package/src/operator-health.js +184 -0
- package/src/password-verifier.js +131 -0
- package/src/service-config.js +547 -0
- package/src/service.js +408 -0
- package/src/sqlite-health.js +83 -0
- package/src/storage-path.js +130 -0
- package/src/store.js +914 -0
- package/src/validation.js +181 -0
- package/src/vision-attachment.js +404 -0
- package/src/web/aginti-client.js +552 -0
- package/src/web/aginti-protocol.js +1146 -0
- package/src/web/asset-map.js +462 -0
- package/src/web/browser-app.js +6491 -0
- package/src/web/cloud-session-client.js +427 -0
- package/src/web/direct-chat-client.js +1482 -0
- package/src/web/index.js +10 -0
- package/src/web/presentation-state.js +107 -0
- package/src/web/pwa-assets.js +854 -0
- package/src/web/pwa-update-handoff-store.js +179 -0
- package/src/web/safe-rendering.js +836 -0
- package/src/web/vision-image-client.js +546 -0
- package/src/web/vision-image-sanitizer.js +168 -0
- package/src/web/web-release.js +28 -0
package/src/service.js
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createAgintiAgentAdapter,
|
|
5
|
+
validateAgintiTransportCredential
|
|
6
|
+
} from './aginti-adapter.js';
|
|
7
|
+
import { DirectChatContextCoordinator } from './chat-context.js';
|
|
8
|
+
import {
|
|
9
|
+
CHAT_SQLITE_APPLICATION_ID,
|
|
10
|
+
DEFAULT_CHAT_SCHEMA_VERSION,
|
|
11
|
+
LATEST_CHAT_SCHEMA_VERSION
|
|
12
|
+
} from './chat-migrations.js';
|
|
13
|
+
import { DirectChatStore } from './chat-store.js';
|
|
14
|
+
import { createCloudServer } from './cloud-server.js';
|
|
15
|
+
import { createDeterministicContextSummarizer } from './deterministic-context-summarizer.js';
|
|
16
|
+
import { createLocalLlmConnector } from './localllm-connector.js';
|
|
17
|
+
import { LATEST_SCHEMA_VERSION, SQLITE_APPLICATION_ID } from './migrations.js';
|
|
18
|
+
import {
|
|
19
|
+
OPERATOR_HEALTH_TIMEOUT_MS,
|
|
20
|
+
createOperatorHealthReport
|
|
21
|
+
} from './operator-health.js';
|
|
22
|
+
import {
|
|
23
|
+
createScryptPasswordVerifier,
|
|
24
|
+
validateScryptPasswordHash
|
|
25
|
+
} from './password-verifier.js';
|
|
26
|
+
import { assertLoadedServiceConfig } from './service-config.js';
|
|
27
|
+
import { checkSqliteFileHealth } from './sqlite-health.js';
|
|
28
|
+
import { CloudIndexStore } from './store.js';
|
|
29
|
+
import { AGINTI_RPC_PATHS } from './web/aginti-protocol.js';
|
|
30
|
+
import {
|
|
31
|
+
createStandaloneAssetMap,
|
|
32
|
+
verifyStandaloneAssetMap
|
|
33
|
+
} from './web/asset-map.js';
|
|
34
|
+
|
|
35
|
+
export const TRUSTED_STANDALONE_BOOTSTRAP_SOURCE = `
|
|
36
|
+
import katex from "./katex.mjs";
|
|
37
|
+
import { createBrowserApp } from "./browser-app.js";
|
|
38
|
+
import { createSafeRenderer } from "./safe-rendering.js";
|
|
39
|
+
|
|
40
|
+
const renderer = createSafeRenderer({ katex });
|
|
41
|
+
const app = createBrowserApp({ renderer });
|
|
42
|
+
void app.initialize();
|
|
43
|
+
`;
|
|
44
|
+
|
|
45
|
+
function sha256(value) {
|
|
46
|
+
return createHash('sha256').update(value, 'utf8').digest('hex');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const OPERATOR_HEALTH_BROWSER_SESSION = sha256('lazying-agent-web/operator-health/v1');
|
|
50
|
+
|
|
51
|
+
function validateTransportCredential(value, label) {
|
|
52
|
+
if (typeof value !== 'string' || value.length < 16 || value.length > 4_096
|
|
53
|
+
|| /[\s\u0000-\u001f\u007f]/u.test(value)) {
|
|
54
|
+
throw new TypeError(`${label} transport credential is invalid`);
|
|
55
|
+
}
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function buildAssetMap(config) {
|
|
60
|
+
const assetMap = await createStandaloneAssetMap({
|
|
61
|
+
bootstrapSource: TRUSTED_STANDALONE_BOOTSTRAP_SOURCE,
|
|
62
|
+
versionLabel: config.pwa.versionLabel,
|
|
63
|
+
basePath: '/',
|
|
64
|
+
title: config.pwa.title,
|
|
65
|
+
loginPath: '/api/login',
|
|
66
|
+
name: config.pwa.name,
|
|
67
|
+
shortName: config.pwa.shortName
|
|
68
|
+
});
|
|
69
|
+
verifyStandaloneAssetMap(assetMap);
|
|
70
|
+
if (assetMap.serviceWorkerRoute !== '/sw.js') {
|
|
71
|
+
throw new TypeError('standalone PWA service worker must remain at stable /sw.js');
|
|
72
|
+
}
|
|
73
|
+
return assetMap;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function materializeInputs(loadedConfig, { createVerifier = true } = {}) {
|
|
77
|
+
const loaded = assertLoadedServiceConfig(loadedConfig);
|
|
78
|
+
let passwordHash = loaded.readCredential('passwordHash');
|
|
79
|
+
let passwordVerifier;
|
|
80
|
+
try {
|
|
81
|
+
if (createVerifier) passwordVerifier = createScryptPasswordVerifier(passwordHash);
|
|
82
|
+
else validateScryptPasswordHash(passwordHash);
|
|
83
|
+
} finally {
|
|
84
|
+
passwordHash = null;
|
|
85
|
+
}
|
|
86
|
+
let localLlmCredential = loaded.readCredential('localLlmToken');
|
|
87
|
+
try {
|
|
88
|
+
validateTransportCredential(localLlmCredential, 'LocalLLM');
|
|
89
|
+
} finally {
|
|
90
|
+
localLlmCredential = null;
|
|
91
|
+
}
|
|
92
|
+
if (loaded.config.aginti.enabled) {
|
|
93
|
+
let agintiCredential = loaded.readCredential('agintiToken');
|
|
94
|
+
try {
|
|
95
|
+
validateAgintiTransportCredential(agintiCredential);
|
|
96
|
+
} finally {
|
|
97
|
+
agintiCredential = null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const assetMap = await buildAssetMap(loaded.config);
|
|
101
|
+
return Object.freeze({
|
|
102
|
+
loaded,
|
|
103
|
+
passwordVerifier,
|
|
104
|
+
assetMap,
|
|
105
|
+
localLlmCredentialProvider: loaded.createCredentialProvider('localLlmToken'),
|
|
106
|
+
...(loaded.config.aginti.enabled
|
|
107
|
+
? { agintiCredentialProvider: loaded.createCredentialProvider('agintiToken') }
|
|
108
|
+
: {})
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function accountProvisionKey(config) {
|
|
113
|
+
return `service-account-v1.${sha256(JSON.stringify({
|
|
114
|
+
principalId: config.account.principalId,
|
|
115
|
+
username: config.account.username,
|
|
116
|
+
displayName: config.account.displayName
|
|
117
|
+
}))}`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function closeStores(controlStore, directChatStore) {
|
|
121
|
+
const failures = [];
|
|
122
|
+
for (const store of [directChatStore, controlStore]) {
|
|
123
|
+
if (!store) continue;
|
|
124
|
+
try {
|
|
125
|
+
store.close();
|
|
126
|
+
} catch (error) {
|
|
127
|
+
failures.push(error);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (failures.length === 1) throw failures[0];
|
|
131
|
+
if (failures.length > 1) throw new AggregateError(failures, 'service stores did not close cleanly');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function safeReport(config, assetMap) {
|
|
135
|
+
return Object.freeze({
|
|
136
|
+
valid: true,
|
|
137
|
+
schema: config.schema,
|
|
138
|
+
listen: Object.freeze({ ...config.listen }),
|
|
139
|
+
publicOrigin: config.publicOrigin,
|
|
140
|
+
releaseId: assetMap.releaseVersion,
|
|
141
|
+
serviceWorkerRoute: assetMap.serviceWorkerRoute,
|
|
142
|
+
agentEnabled: false,
|
|
143
|
+
agentConfigured: config.aginti.enabled,
|
|
144
|
+
directChatEnabled: true
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function safeEdgeRouteManifest(config, assetMap) {
|
|
149
|
+
verifyStandaloneAssetMap(assetMap);
|
|
150
|
+
const requestTargets = Object.freeze([...assetMap.routes]);
|
|
151
|
+
const paths = Object.freeze([...new Set(requestTargets.map((target) => target.split('?', 1)[0]))].sort());
|
|
152
|
+
if (paths.some((pathname) => typeof pathname !== 'string' || !pathname.startsWith('/'))
|
|
153
|
+
|| requestTargets.some((target) => typeof target !== 'string' || !target.startsWith('/'))) {
|
|
154
|
+
throw new TypeError('standalone PWA edge route manifest is invalid');
|
|
155
|
+
}
|
|
156
|
+
return Object.freeze({
|
|
157
|
+
schema: 'lazying-agent-web/edge-route-manifest/v1',
|
|
158
|
+
publicOrigin: config.publicOrigin,
|
|
159
|
+
releaseId: assetMap.releaseVersion,
|
|
160
|
+
methods: Object.freeze(['GET', 'HEAD']),
|
|
161
|
+
paths,
|
|
162
|
+
requestTargets
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function checkStandaloneServiceConfiguration(loadedConfig) {
|
|
167
|
+
const materialized = await materializeInputs(loadedConfig, { createVerifier: false });
|
|
168
|
+
return safeReport(materialized.loaded.config, materialized.assetMap);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export async function createStandaloneServiceEdgeRouteManifest(loadedConfig) {
|
|
172
|
+
const materialized = await materializeInputs(loadedConfig, { createVerifier: false });
|
|
173
|
+
return safeEdgeRouteManifest(materialized.loaded.config, materialized.assetMap);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export async function checkStandaloneServiceHealth(loadedConfig, {
|
|
177
|
+
fetchImpl,
|
|
178
|
+
dependencyTimeoutMs = OPERATOR_HEALTH_TIMEOUT_MS,
|
|
179
|
+
databaseHealthChecker = checkSqliteFileHealth,
|
|
180
|
+
clock
|
|
181
|
+
} = {}) {
|
|
182
|
+
if (fetchImpl !== undefined && typeof fetchImpl !== 'function') {
|
|
183
|
+
throw new TypeError('fetchImpl must be a function');
|
|
184
|
+
}
|
|
185
|
+
if (typeof databaseHealthChecker !== 'function') {
|
|
186
|
+
throw new TypeError('databaseHealthChecker must be a function');
|
|
187
|
+
}
|
|
188
|
+
if (clock !== undefined && typeof clock !== 'function') throw new TypeError('clock must be a function');
|
|
189
|
+
|
|
190
|
+
const loaded = assertLoadedServiceConfig(loadedConfig);
|
|
191
|
+
const config = loaded.config;
|
|
192
|
+
const assetMap = await buildAssetMap(config);
|
|
193
|
+
const localLlmConnector = createLocalLlmConnector({
|
|
194
|
+
baseUrl: config.localLlm.baseUrl,
|
|
195
|
+
allowedModelAliases: config.localLlm.allowedModelAliases,
|
|
196
|
+
credentialProvider: loaded.createCredentialProvider('localLlmToken'),
|
|
197
|
+
...(fetchImpl === undefined ? {} : { fetchImpl })
|
|
198
|
+
});
|
|
199
|
+
const agintiAdapter = config.aginti.enabled
|
|
200
|
+
? createAgintiAgentAdapter({
|
|
201
|
+
upstream: config.aginti.baseUrl,
|
|
202
|
+
credentialProvider: loaded.createCredentialProvider('agintiToken'),
|
|
203
|
+
...(fetchImpl === undefined ? {} : { fetchImpl })
|
|
204
|
+
})
|
|
205
|
+
: null;
|
|
206
|
+
const allowedDirectChatSchemas = config.localLlm.vision.enabled
|
|
207
|
+
? [LATEST_CHAT_SCHEMA_VERSION]
|
|
208
|
+
: [...new Set([DEFAULT_CHAT_SCHEMA_VERSION, LATEST_CHAT_SCHEMA_VERSION])];
|
|
209
|
+
|
|
210
|
+
return createOperatorHealthReport({
|
|
211
|
+
releaseId: assetMap.releaseVersion,
|
|
212
|
+
cloudIndexProbe: () => databaseHealthChecker({
|
|
213
|
+
databasePath: config.state.cloudIndexDatabase,
|
|
214
|
+
expectedApplicationId: SQLITE_APPLICATION_ID,
|
|
215
|
+
allowedSchemaVersions: [LATEST_SCHEMA_VERSION]
|
|
216
|
+
}),
|
|
217
|
+
directChatProbe: () => databaseHealthChecker({
|
|
218
|
+
databasePath: config.state.directChatDatabase,
|
|
219
|
+
expectedApplicationId: CHAT_SQLITE_APPLICATION_ID,
|
|
220
|
+
allowedSchemaVersions: allowedDirectChatSchemas
|
|
221
|
+
}),
|
|
222
|
+
localLlmProbe: ({ signal }) => localLlmConnector.readiness({ signal }),
|
|
223
|
+
agintiProbe: agintiAdapter === null
|
|
224
|
+
? null
|
|
225
|
+
: ({ signal }) => agintiAdapter.rpc(AGINTI_RPC_PATHS.capabilities, {}, {
|
|
226
|
+
principalId: config.account.principalId,
|
|
227
|
+
browserSession: OPERATOR_HEALTH_BROWSER_SESSION,
|
|
228
|
+
signal
|
|
229
|
+
}),
|
|
230
|
+
dependencyTimeoutMs,
|
|
231
|
+
...(clock === undefined ? {} : { clock })
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export async function createStandaloneService({
|
|
236
|
+
loadedConfig,
|
|
237
|
+
fetchImpl,
|
|
238
|
+
localSummarizer,
|
|
239
|
+
serverFactory = createCloudServer,
|
|
240
|
+
clock
|
|
241
|
+
} = {}) {
|
|
242
|
+
if (typeof serverFactory !== 'function') throw new TypeError('serverFactory must be a function');
|
|
243
|
+
if (fetchImpl !== undefined && typeof fetchImpl !== 'function') {
|
|
244
|
+
throw new TypeError('fetchImpl must be a function');
|
|
245
|
+
}
|
|
246
|
+
if (localSummarizer !== undefined && localSummarizer !== null
|
|
247
|
+
&& (typeof localSummarizer !== 'object' || localSummarizer.locality !== 'local'
|
|
248
|
+
|| typeof localSummarizer.summarizeDirectChat !== 'function')) {
|
|
249
|
+
throw new TypeError('localSummarizer must be a local-only Direct Chat summarizer');
|
|
250
|
+
}
|
|
251
|
+
if (clock !== undefined && typeof clock !== 'function') throw new TypeError('clock must be a function');
|
|
252
|
+
|
|
253
|
+
const materialized = await materializeInputs(loadedConfig);
|
|
254
|
+
const config = materialized.loaded.config;
|
|
255
|
+
let controlStore;
|
|
256
|
+
let directChatStore;
|
|
257
|
+
let server;
|
|
258
|
+
try {
|
|
259
|
+
controlStore = new CloudIndexStore({
|
|
260
|
+
databasePath: config.state.cloudIndexDatabase,
|
|
261
|
+
...(clock === undefined ? {} : { clock })
|
|
262
|
+
});
|
|
263
|
+
directChatStore = new DirectChatStore({
|
|
264
|
+
databasePath: config.state.directChatDatabase,
|
|
265
|
+
modelAlias: config.localLlm.defaultModelAlias,
|
|
266
|
+
visionModelAlias: config.localLlm.vision.modelAlias,
|
|
267
|
+
enableVisionAttachments: config.localLlm.vision.enabled,
|
|
268
|
+
...(clock === undefined ? {} : { clock })
|
|
269
|
+
});
|
|
270
|
+
const account = controlStore.provisionAccount({
|
|
271
|
+
accountId: config.account.principalId,
|
|
272
|
+
issuer: 'local-login',
|
|
273
|
+
subject: config.account.username,
|
|
274
|
+
displayName: config.account.displayName,
|
|
275
|
+
idempotencyKey: accountProvisionKey(config)
|
|
276
|
+
});
|
|
277
|
+
if (account.issuer !== 'local-login' || account.subject !== config.account.username
|
|
278
|
+
|| account.displayName !== config.account.displayName) {
|
|
279
|
+
throw new TypeError('provisioned account does not match the immutable service identity config');
|
|
280
|
+
}
|
|
281
|
+
const directChatSummarizer = localSummarizer ?? createDeterministicContextSummarizer();
|
|
282
|
+
const directChatContext = new DirectChatContextCoordinator({
|
|
283
|
+
store: directChatStore,
|
|
284
|
+
localSummarizer: directChatSummarizer
|
|
285
|
+
});
|
|
286
|
+
const directChatConnector = createLocalLlmConnector({
|
|
287
|
+
baseUrl: config.localLlm.baseUrl,
|
|
288
|
+
allowedModelAliases: config.localLlm.allowedModelAliases,
|
|
289
|
+
credentialProvider: materialized.localLlmCredentialProvider,
|
|
290
|
+
...(fetchImpl === undefined ? {} : { fetchImpl })
|
|
291
|
+
});
|
|
292
|
+
const agintiAdapter = config.aginti.enabled
|
|
293
|
+
? createAgintiAgentAdapter({
|
|
294
|
+
upstream: config.aginti.baseUrl,
|
|
295
|
+
credentialProvider: materialized.agintiCredentialProvider,
|
|
296
|
+
...(fetchImpl === undefined ? {} : { fetchImpl })
|
|
297
|
+
})
|
|
298
|
+
: null;
|
|
299
|
+
server = serverFactory({
|
|
300
|
+
releaseId: materialized.assetMap.releaseVersion,
|
|
301
|
+
assetMap: materialized.assetMap,
|
|
302
|
+
publicOrigin: config.publicOrigin,
|
|
303
|
+
account: {
|
|
304
|
+
username: config.account.username,
|
|
305
|
+
principalId: config.account.principalId
|
|
306
|
+
},
|
|
307
|
+
passwordVerifier: materialized.passwordVerifier,
|
|
308
|
+
sessionStore: controlStore,
|
|
309
|
+
controlStore,
|
|
310
|
+
directChatStore,
|
|
311
|
+
directChatContext,
|
|
312
|
+
directChatSummarizer,
|
|
313
|
+
directChatConnector,
|
|
314
|
+
visionEnabled: config.localLlm.vision.enabled,
|
|
315
|
+
visionModelAlias: config.localLlm.vision.modelAlias,
|
|
316
|
+
agintiAdapter,
|
|
317
|
+
requestOutcomeObserver(outcome) {
|
|
318
|
+
if (outcome.result === 'rejected') {
|
|
319
|
+
console.warn(JSON.stringify({ event: 'cloud_request_outcome', ...outcome }));
|
|
320
|
+
}
|
|
321
|
+
},
|
|
322
|
+
...(clock === undefined ? {} : { clock })
|
|
323
|
+
});
|
|
324
|
+
if (!server || typeof server.listen !== 'function' || typeof server.shutdown !== 'function'
|
|
325
|
+
|| typeof server.address !== 'function' || typeof server.once !== 'function'
|
|
326
|
+
|| typeof server.removeListener !== 'function') {
|
|
327
|
+
throw new TypeError('serverFactory returned an invalid graceful server');
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
let startPromise = null;
|
|
331
|
+
let shutdownPromise = null;
|
|
332
|
+
let stopped = false;
|
|
333
|
+
const start = () => {
|
|
334
|
+
if (stopped) return Promise.reject(new Error('standalone service is already stopped'));
|
|
335
|
+
if (startPromise) return startPromise;
|
|
336
|
+
startPromise = new Promise((resolve, reject) => {
|
|
337
|
+
const onError = (error) => {
|
|
338
|
+
server.removeListener('error', onError);
|
|
339
|
+
reject(error);
|
|
340
|
+
};
|
|
341
|
+
server.once('error', onError);
|
|
342
|
+
server.listen(config.listen.port, '127.0.0.1', () => {
|
|
343
|
+
server.removeListener('error', onError);
|
|
344
|
+
const address = server.address();
|
|
345
|
+
if (!address || typeof address !== 'object' || address.address !== '127.0.0.1'
|
|
346
|
+
|| address.port !== config.listen.port) {
|
|
347
|
+
void shutdown();
|
|
348
|
+
reject(new Error('server did not bind the exact configured loopback endpoint'));
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
resolve(Object.freeze({ address: '127.0.0.1', port: address.port }));
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
return startPromise;
|
|
355
|
+
};
|
|
356
|
+
const shutdown = () => {
|
|
357
|
+
if (shutdownPromise) return shutdownPromise;
|
|
358
|
+
stopped = true;
|
|
359
|
+
shutdownPromise = (async () => {
|
|
360
|
+
const failures = [];
|
|
361
|
+
try {
|
|
362
|
+
await server.shutdown();
|
|
363
|
+
} catch (error) {
|
|
364
|
+
failures.push(error);
|
|
365
|
+
}
|
|
366
|
+
try {
|
|
367
|
+
closeStores(controlStore, directChatStore);
|
|
368
|
+
} catch (error) {
|
|
369
|
+
failures.push(error);
|
|
370
|
+
}
|
|
371
|
+
if (failures.length === 1) throw failures[0];
|
|
372
|
+
if (failures.length > 1) throw new AggregateError(failures, 'standalone service shutdown failed');
|
|
373
|
+
})();
|
|
374
|
+
return shutdownPromise;
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
return Object.freeze({
|
|
378
|
+
kind: 'lazying-agent-standalone-service',
|
|
379
|
+
releaseId: materialized.assetMap.releaseVersion,
|
|
380
|
+
listen: Object.freeze({ ...config.listen }),
|
|
381
|
+
publicOrigin: config.publicOrigin,
|
|
382
|
+
account: Object.freeze({ ...account }),
|
|
383
|
+
agentEnabled: false,
|
|
384
|
+
assetMap: materialized.assetMap,
|
|
385
|
+
server,
|
|
386
|
+
controlStore,
|
|
387
|
+
directChatStore,
|
|
388
|
+
directChatContext,
|
|
389
|
+
directChatSummarizer,
|
|
390
|
+
directChatConnector,
|
|
391
|
+
agintiAdapter,
|
|
392
|
+
start,
|
|
393
|
+
shutdown
|
|
394
|
+
});
|
|
395
|
+
} catch (error) {
|
|
396
|
+
try {
|
|
397
|
+
if (server?.shutdown) await server.shutdown();
|
|
398
|
+
} catch {
|
|
399
|
+
// Preserve the construction failure.
|
|
400
|
+
}
|
|
401
|
+
try {
|
|
402
|
+
closeStores(controlStore, directChatStore);
|
|
403
|
+
} catch {
|
|
404
|
+
// Preserve the construction failure.
|
|
405
|
+
}
|
|
406
|
+
throw error;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { StorageCorruptionError, ValidationError } from './errors.js';
|
|
5
|
+
import { assertSecureDatabaseFile } from './storage-path.js';
|
|
6
|
+
|
|
7
|
+
const SQLITE_VERSION_PATTERN = /^\d{1,3}\.\d{1,3}\.\d{1,3}$/u;
|
|
8
|
+
|
|
9
|
+
function normalizedSchemaVersions(value) {
|
|
10
|
+
if (!Array.isArray(value) || value.length < 1 || value.length > 4) {
|
|
11
|
+
throw new ValidationError('allowedSchemaVersions must be a short non-empty array.');
|
|
12
|
+
}
|
|
13
|
+
const versions = [...new Set(value)];
|
|
14
|
+
if (versions.length !== value.length
|
|
15
|
+
|| versions.some((version) => !Number.isSafeInteger(version) || version < 1)) {
|
|
16
|
+
throw new ValidationError('allowedSchemaVersions contains an invalid schema version.');
|
|
17
|
+
}
|
|
18
|
+
return Object.freeze(versions);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function checkOpenSqliteHealth(database, {
|
|
22
|
+
expectedApplicationId,
|
|
23
|
+
allowedSchemaVersions
|
|
24
|
+
} = {}) {
|
|
25
|
+
if (!database || typeof database.prepare !== 'function') {
|
|
26
|
+
throw new ValidationError('database must provide prepare().');
|
|
27
|
+
}
|
|
28
|
+
if (!Number.isSafeInteger(expectedApplicationId) || expectedApplicationId < 1) {
|
|
29
|
+
throw new ValidationError('expectedApplicationId is invalid.');
|
|
30
|
+
}
|
|
31
|
+
const schemaVersions = normalizedSchemaVersions(allowedSchemaVersions);
|
|
32
|
+
|
|
33
|
+
const quickCheck = database.prepare('PRAGMA quick_check').get();
|
|
34
|
+
if (quickCheck?.quick_check !== 'ok') throw new StorageCorruptionError();
|
|
35
|
+
if (database.prepare('PRAGMA foreign_key_check').all().length !== 0) {
|
|
36
|
+
throw new StorageCorruptionError('The database contains invalid ownership references.');
|
|
37
|
+
}
|
|
38
|
+
const schemaVersion = Number(database.prepare('PRAGMA user_version').get()?.user_version);
|
|
39
|
+
const applicationId = Number(database.prepare('PRAGMA application_id').get()?.application_id);
|
|
40
|
+
if (!schemaVersions.includes(schemaVersion) || applicationId !== expectedApplicationId) {
|
|
41
|
+
throw new StorageCorruptionError('The database identity or schema version changed unexpectedly.');
|
|
42
|
+
}
|
|
43
|
+
const sqliteVersion = database.prepare('SELECT sqlite_version() AS version').get()?.version;
|
|
44
|
+
if (typeof sqliteVersion !== 'string' || !SQLITE_VERSION_PATTERN.test(sqliteVersion)) {
|
|
45
|
+
throw new StorageCorruptionError('SQLite returned an invalid runtime version.');
|
|
46
|
+
}
|
|
47
|
+
return Object.freeze({ ready: true, schemaVersion, sqliteVersion });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function checkSqliteFileHealth({
|
|
51
|
+
databasePath,
|
|
52
|
+
expectedApplicationId,
|
|
53
|
+
allowedSchemaVersions
|
|
54
|
+
} = {}) {
|
|
55
|
+
if (typeof databasePath !== 'string' || !isAbsolute(databasePath)
|
|
56
|
+
|| resolve(databasePath) !== databasePath) {
|
|
57
|
+
throw new ValidationError('databasePath must be an absolute normalized path.');
|
|
58
|
+
}
|
|
59
|
+
assertSecureDatabaseFile(databasePath);
|
|
60
|
+
let database;
|
|
61
|
+
try {
|
|
62
|
+
database = new DatabaseSync(databasePath, { readOnly: true });
|
|
63
|
+
database.enableLoadExtension(false);
|
|
64
|
+
database.exec(`
|
|
65
|
+
PRAGMA busy_timeout = 1000;
|
|
66
|
+
PRAGMA query_only = ON;
|
|
67
|
+
PRAGMA trusted_schema = OFF;
|
|
68
|
+
`);
|
|
69
|
+
return checkOpenSqliteHealth(database, {
|
|
70
|
+
expectedApplicationId,
|
|
71
|
+
allowedSchemaVersions
|
|
72
|
+
});
|
|
73
|
+
} catch (error) {
|
|
74
|
+
if (error instanceof StorageCorruptionError || error instanceof ValidationError) throw error;
|
|
75
|
+
throw new StorageCorruptionError('The database could not be inspected read-only.', { cause: error });
|
|
76
|
+
} finally {
|
|
77
|
+
try {
|
|
78
|
+
database?.close();
|
|
79
|
+
} finally {
|
|
80
|
+
assertSecureDatabaseFile(databasePath);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import {
|
|
2
|
+
chmodSync,
|
|
3
|
+
closeSync,
|
|
4
|
+
constants,
|
|
5
|
+
lstatSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
openSync,
|
|
8
|
+
realpathSync
|
|
9
|
+
} from 'node:fs';
|
|
10
|
+
import { dirname, isAbsolute, parse, resolve, sep } from 'node:path';
|
|
11
|
+
|
|
12
|
+
import { StorageSecurityError, ValidationError } from './errors.js';
|
|
13
|
+
|
|
14
|
+
function currentUid() {
|
|
15
|
+
return typeof process.getuid === 'function' ? process.getuid() : null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function assertOwned(stat, label) {
|
|
19
|
+
const uid = currentUid();
|
|
20
|
+
if (uid !== null && stat.uid !== uid) {
|
|
21
|
+
throw new StorageSecurityError(`${label} must be owned by the current user.`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function assertPrivateMode(stat, label) {
|
|
26
|
+
if ((stat.mode & 0o077) !== 0) {
|
|
27
|
+
throw new StorageSecurityError(`${label} must not be accessible by group or other users.`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function lstatIfPresent(pathname) {
|
|
32
|
+
try {
|
|
33
|
+
return lstatSync(pathname);
|
|
34
|
+
} catch (error) {
|
|
35
|
+
if (error?.code === 'ENOENT') return null;
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function pathComponents(absolutePath) {
|
|
41
|
+
const { root } = parse(absolutePath);
|
|
42
|
+
const relative = absolutePath.slice(root.length);
|
|
43
|
+
const parts = relative.length === 0 ? [] : relative.split(sep).filter(Boolean);
|
|
44
|
+
const paths = [root];
|
|
45
|
+
let current = root;
|
|
46
|
+
for (const part of parts) {
|
|
47
|
+
current = resolve(current, part);
|
|
48
|
+
paths.push(current);
|
|
49
|
+
}
|
|
50
|
+
return paths;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function assertNoSymlinkComponents(absolutePath) {
|
|
54
|
+
for (const candidate of pathComponents(absolutePath)) {
|
|
55
|
+
const stat = lstatIfPresent(candidate);
|
|
56
|
+
if (!stat) break;
|
|
57
|
+
if (stat.isSymbolicLink()) {
|
|
58
|
+
throw new StorageSecurityError(`Storage path component ${JSON.stringify(candidate)} must not be a symbolic link.`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function prepareSecureDatabasePath(databasePath) {
|
|
64
|
+
if (typeof databasePath !== 'string' || databasePath.length === 0 || !isAbsolute(databasePath)) {
|
|
65
|
+
throw new ValidationError('databasePath must be a non-empty absolute filesystem path.');
|
|
66
|
+
}
|
|
67
|
+
const resolvedPath = resolve(databasePath);
|
|
68
|
+
const stateDirectory = dirname(resolvedPath);
|
|
69
|
+
if (stateDirectory === resolvedPath) {
|
|
70
|
+
throw new StorageSecurityError('databasePath must name a file inside a private state directory.');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
assertNoSymlinkComponents(stateDirectory);
|
|
74
|
+
mkdirSync(stateDirectory, { recursive: true, mode: 0o700 });
|
|
75
|
+
assertNoSymlinkComponents(stateDirectory);
|
|
76
|
+
|
|
77
|
+
const directoryStat = lstatSync(stateDirectory);
|
|
78
|
+
if (!directoryStat.isDirectory()) {
|
|
79
|
+
throw new StorageSecurityError('The database parent must be a directory.');
|
|
80
|
+
}
|
|
81
|
+
assertOwned(directoryStat, 'The database state directory');
|
|
82
|
+
assertPrivateMode(directoryStat, 'The database state directory');
|
|
83
|
+
|
|
84
|
+
if (realpathSync(stateDirectory) !== stateDirectory) {
|
|
85
|
+
throw new StorageSecurityError('The database state directory must resolve without indirection.');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const noFollow = constants.O_NOFOLLOW ?? 0;
|
|
89
|
+
const existingDatabase = lstatIfPresent(resolvedPath);
|
|
90
|
+
if (!existingDatabase) {
|
|
91
|
+
try {
|
|
92
|
+
const descriptor = openSync(
|
|
93
|
+
resolvedPath,
|
|
94
|
+
constants.O_CREAT | constants.O_EXCL | constants.O_RDWR | noFollow,
|
|
95
|
+
0o600
|
|
96
|
+
);
|
|
97
|
+
closeSync(descriptor);
|
|
98
|
+
chmodSync(resolvedPath, 0o600);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
throw new StorageSecurityError('The database file could not be created without following links.', { cause: error });
|
|
101
|
+
}
|
|
102
|
+
} else {
|
|
103
|
+
assertSecureDatabaseFile(resolvedPath);
|
|
104
|
+
try {
|
|
105
|
+
const descriptor = openSync(resolvedPath, constants.O_RDWR | noFollow);
|
|
106
|
+
closeSync(descriptor);
|
|
107
|
+
} catch (error) {
|
|
108
|
+
throw new StorageSecurityError('The database file could not be opened without following links.', { cause: error });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
assertSecureDatabaseFile(resolvedPath);
|
|
113
|
+
return resolvedPath;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function assertSecureDatabaseFile(databasePath) {
|
|
117
|
+
assertNoSymlinkComponents(databasePath);
|
|
118
|
+
const stat = lstatSync(databasePath);
|
|
119
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
120
|
+
throw new StorageSecurityError('The database path must be a regular file, not a link or device.');
|
|
121
|
+
}
|
|
122
|
+
if (stat.nlink !== 1) {
|
|
123
|
+
throw new StorageSecurityError('The database file must not have additional hard links.');
|
|
124
|
+
}
|
|
125
|
+
assertOwned(stat, 'The database file');
|
|
126
|
+
assertPrivateMode(stat, 'The database file');
|
|
127
|
+
if (realpathSync(databasePath) !== databasePath) {
|
|
128
|
+
throw new StorageSecurityError('The database file must resolve without indirection.');
|
|
129
|
+
}
|
|
130
|
+
}
|