mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-11-12 09:45:47 +00:00
* 🛡️ fix: Reject Unknown Admin Config Sections and Scope Write Responses to Read Access - Admin config writes (PUT, PATCH, tombstone) now accept only top-level sections that configSchema defines. PUT reports `unknown_section` issues under CONFIG_OVERRIDE_INVALID; PATCH and tombstone return 400 UNKNOWN_SECTION without echoing the path. This closes override writes to AppService runtime state (`paths`, the raw-YAML `config`, `availableTools`, and the `interfaceConfig`/`turnstileConfig`/`mcpConfig` aliases). - mergeConfigOverrides ignores stored override keys and tombstones outside configSchema, with one warning per document, so data an older server stored is neutralized without a migration. DELETE /fields still removes such stored entries, which is the only per-field way to clear a legacy tombstone. - Write responses (PUT, PATCH, tombstone, field delete, toggle) are redacted and then filtered to the sections the caller can read plus the sections the write managed; broad managers see the whole document as before. - Admin reads of the resolved config redact literal Azure group keys (merged `groups`, `groupMap`, raw YAML copy) and the Vertex `serviceKeyFile` (`vertex` and `vertexConfig`), keeping `${ENV}` and `user_provided` references readable. Write behaviour for these paths is unchanged. - The experimental cluster entry boots from librechat.yaml only (`baseOnly`), like server/index.js, so `__base__` overrides no longer reach file storage, skill sync, skill deletion or the expired-file sweep at boot. * 🛡️ fix: Resolve Admin Config Write Read Access Before Committing - Section-scoped writes (PATCH, tombstone, field delete) resolve the caller's readable sections before the mutation, alongside the pre-write reads they already make, instead of after the write and audit have committed. The batched lookup covers every `configSchema` section, so it no longer waits on the stored document. - A failed grant lookup now fails the request before anything is stored, so a 500 never follows a committed write and invites a duplicate retry. Response filtering after the write is pure. - Assign-only scope creation and toggles match only empty documents (`expectEmpty`), so their responses skip the lookup entirely. * 🚩 feat: Report the Section Allowlist and Read-Filtered Write Responses in Config Meta - `GET /api/admin/config/meta` now reports `sectionAllowlist: true` and `readFilteredWriteResponses: true`, which this branch implements (BE-17, BE-18). - The meta.spec probes check each flag against the handlers rather than the validator alone: PUT answers `unknown_section` with `segments`, PATCH and tombstone answer `UNKNOWN_SECTION`, a stored `paths` override and tombstone are ignored at merge, and PATCH, tombstone and field delete responses omit a section the caller cannot read. - A dotted top-level key is reported once, as `dotted_key`, instead of also as `unknown_section`. - The unknown-section handler and integration tests expect the `segments` that issues carry since #17322. * 🧪 test: Type the Tombstoned Meta Probe Document Through storedConfig * 🧪 test: Align Allowlist and Filtered-Response Specs with Version-Checked Web Search Writes dev's version-checked webSearch writes pass a session and precondition to patchConfigFields/unsetConfigField, read the stored document before removing a binding path, and have getBaseConfig load the librechat.yaml config beside a merged read. The field delete spec now stores the document it removes from and asserts the precondition alongside the filtered response. * 🧪 test: Pin Section-Manager Write Responses to What a Read Shows The grant layer resolves read:configs:<section> from the same section's manage:configs:<section> (getParentCapabilities, #14472), so a section manager's GET already returns the whole managed section. The comment on resolveWriteReadAccess said the opposite; it now states the model. A new integration spec runs the handlers over real SystemGrant documents through generateCapabilityCheck: a caller holding only manage:configs:endpoints patches one endpoints field, and its write response equals what GET returns it, other endpoint overrides and tombstones included, with memory and webSearch left out of both. * 🧪 test: Mark Config-Service Generation Tests With a Schema Section The base-config generation tests that came with the portable config store marked each base config and override layer with an `x` key, which no section in `configSchema` defines. This branch dropped `x` from the spec's `TestConfig` and ignores such keys when overrides are merged, so those tests no longer typechecked, and the override-layer ones would stop seeing the layer they read. They now mark layers with the `version` section, as this branch's other service tests do. * 🧪 test: Expect the yamlMcpHide Flag Beside the Section Allowlist Flags #17327 publishes features.yamlMcpHide on dev, so the exact feature list this PR asserts in meta.spec now includes it.
158 lines
7.2 KiB
JavaScript
158 lines
7.2 KiB
JavaScript
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
describe('Experimental server configuration', () => {
|
|
const source = fs.readFileSync(path.join(__dirname, 'experimental.js'), 'utf8');
|
|
|
|
it('configures HTTP timeouts for each cluster worker server', () => {
|
|
const listenIndex = source.indexOf('const server = app.listen');
|
|
const timeoutConfigIndex = source.indexOf('configureServerTimeouts(server);');
|
|
|
|
expect(listenIndex).toBeGreaterThan(-1);
|
|
expect(timeoutConfigIndex).toBeGreaterThan(-1);
|
|
expect(listenIndex).toBeLessThan(timeoutConfigIndex);
|
|
});
|
|
|
|
it('lets each worker drain registered services before cluster shutdown', () => {
|
|
const listenIndex = source.indexOf('const server = app.listen');
|
|
const gracefulShutdownIndex = source.indexOf('setupGracefulShutdown(server);');
|
|
|
|
expect(gracefulShutdownIndex).toBeGreaterThan(-1);
|
|
expect(listenIndex).toBeLessThan(gracefulShutdownIndex);
|
|
expect(source).toContain('if (shuttingDown) {');
|
|
expect(source).toMatch(/if \(shuttingDown\) \{[\s\S]*?return;[\s\S]*?Starting a new worker/);
|
|
});
|
|
|
|
it("drains background tasks within the primary's cluster shutdown deadline", () => {
|
|
expect(source).toMatch(
|
|
/registerBackgroundTaskShutdown\(\{[\s\S]*?getBudgetMs: clusterShutdownBudgetMs,[\s\S]*?\}\);/,
|
|
);
|
|
expect(source).toMatch(
|
|
/const clusterShutdownBudgetMs = \(\) =>\s*getClusterShutdownBudgetMs\(\{\s*deadlineAt: clusterShutdownDeadlineAt,\s*forceExitMs: CLUSTER_FORCE_EXIT_MS,\s*\}\);/,
|
|
);
|
|
expect(source).toMatch(
|
|
/const destroyGenerationJobManager = \(\) => \{\s*const budgetMs = clusterShutdownBudgetMs\(\);/,
|
|
);
|
|
});
|
|
|
|
it('starts approval expiry after installing the scheduled-run callback', () => {
|
|
const handlerIndex = source.indexOf(
|
|
'GenerationJobManager.setApprovalExpiredHandler(recordExpiredScheduleApproval);',
|
|
);
|
|
const initializeIndex = source.indexOf('GenerationJobManager.initialize();');
|
|
|
|
expect(handlerIndex).toBeGreaterThan(-1);
|
|
expect(initializeIndex).toBeGreaterThan(handlerIndex);
|
|
});
|
|
|
|
it('starts erasure-only schedule maintenance after connecting to Mongo, once per worker', () => {
|
|
const connectIndex = source.indexOf('await connectDb();');
|
|
const sweepIndex = source.indexOf('initializeScheduleErasureSweep();');
|
|
|
|
expect(connectIndex).toBeGreaterThan(-1);
|
|
expect(sweepIndex).toBeGreaterThan(-1);
|
|
// Mongo must be up before the sweep reads soft-deleted rows.
|
|
expect(sweepIndex).toBeGreaterThan(connectIndex);
|
|
// Idempotent guard lives in the service; started exactly once from this entrypoint.
|
|
expect(source.match(/initializeScheduleErasureSweep\(\);/g)).toHaveLength(1);
|
|
});
|
|
|
|
it('starts code-environment lifecycle reconciliation after Mongo connects in each worker', () => {
|
|
const connectIndex = source.indexOf('await connectDb();');
|
|
const reconcileIndex = source.indexOf('startCodeEnvironmentLifecycleReconciler({ mongoose });');
|
|
const listenIndex = source.indexOf('const server = app.listen');
|
|
|
|
expect(connectIndex).toBeGreaterThan(-1);
|
|
expect(reconcileIndex).toBeGreaterThan(connectIndex);
|
|
expect(listenIndex).toBeGreaterThan(reconcileIndex);
|
|
expect(
|
|
source.match(/startCodeEnvironmentLifecycleReconciler\(\{ mongoose \}\);/g),
|
|
).toHaveLength(1);
|
|
});
|
|
|
|
it('never arms the full schedule engine in a clustered worker', () => {
|
|
// The clustered entrypoint runs erasure-only maintenance: arming the engine here
|
|
// would claim/fire/absence-reconcile runs whose peer generations it cannot see.
|
|
expect(source).not.toContain('initializeScheduleEngine(');
|
|
});
|
|
|
|
it('runs cross-tenant startup work in the system context', () => {
|
|
expect(source).toContain('await runAsSystem(seedDatabase);');
|
|
expect(source).toMatch(
|
|
/await runAsSystem\(async \(\) => \{\s+await performStartupChecks\(appConfig\);\s+await updateInterfacePerms/,
|
|
);
|
|
});
|
|
|
|
it('configures routed subagent controls before a worker accepts requests', () => {
|
|
const redisReadyIndex = source.indexOf('await waitForKeyvRedisClient();');
|
|
const routingIndex = source.indexOf(
|
|
'await configureSubagentTaskRouting(appConfig?.endpoints?.agents?.subagentActivity);',
|
|
);
|
|
const listenIndex = source.indexOf('const server = app.listen');
|
|
|
|
expect(redisReadyIndex).toBeGreaterThan(-1);
|
|
expect(routingIndex).toBeGreaterThan(redisReadyIndex);
|
|
expect(listenIndex).toBeGreaterThan(routingIndex);
|
|
});
|
|
|
|
it('projects base-only event rollout barriers before accepting requests', () => {
|
|
const baseConfigIndex = source.indexOf(
|
|
'const appConfig = await getAppConfig({ baseOnly: true });',
|
|
);
|
|
const eventRuntimeIndex = source.indexOf(
|
|
'configureAgentEventRuntime(appConfig?.endpoints?.agents?.eventDriven);',
|
|
);
|
|
const listenIndex = source.indexOf('const server = app.listen');
|
|
|
|
expect(baseConfigIndex).toBeGreaterThan(-1);
|
|
expect(eventRuntimeIndex).toBeGreaterThan(baseConfigIndex);
|
|
expect(listenIndex).toBeGreaterThan(eventRuntimeIndex);
|
|
});
|
|
|
|
it('passes the same idle recovery policy to both server startup paths', () => {
|
|
const standard = fs.readFileSync(path.join(__dirname, 'index.js'), 'utf8');
|
|
for (const entrypoint of [source, standard]) {
|
|
const start = entrypoint.indexOf('await initializeAgentTriggerService({');
|
|
expect(start).toBeGreaterThan(-1);
|
|
const call = entrypoint.slice(start, entrypoint.indexOf('});', start));
|
|
expect(call).toContain('idlePolling: appConfig?.endpoints?.agents?.eventDriven?.idlePolling');
|
|
expect(call.replace(/\s+/g, ' ')).toContain(
|
|
'deadLetterRetentionDays: appConfig?.endpoints?.agents?.eventDriven?.deadLetterRetentionDays',
|
|
);
|
|
}
|
|
});
|
|
|
|
it('boots from librechat.yaml only, like the standard entry, so `__base__` overrides never reach worker-wide state', () => {
|
|
const standard = fs.readFileSync(path.join(__dirname, 'index.js'), 'utf8');
|
|
for (const entrypoint of [source, standard]) {
|
|
const reads = entrypoint.match(/await getAppConfig\([^)]*\)/g);
|
|
expect(reads).toEqual(['await getAppConfig({ baseOnly: true })']);
|
|
const readIndex = entrypoint.indexOf(
|
|
'const appConfig = await getAppConfig({ baseOnly: true });',
|
|
);
|
|
expect(readIndex).toBeGreaterThan(-1);
|
|
for (const consumer of [
|
|
'initializeFileStorage(appConfig);',
|
|
'initializeGitHubSkillSync(appConfig);',
|
|
'startSkillDeletionSweep(appConfig.skillDeletion);',
|
|
'await performStartupChecks(appConfig);',
|
|
'await configureSocialLogins(app, appConfig);',
|
|
]) {
|
|
expect(entrypoint.indexOf(consumer)).toBeGreaterThan(readIndex);
|
|
}
|
|
}
|
|
expect(source).toContain(
|
|
'expiredFileSweep.configure({ appConfig, loadAppConfig: getAppConfig });',
|
|
);
|
|
expect(source).not.toContain('baseAppConfig');
|
|
});
|
|
|
|
it('matches the standard server pre-authentication tenant routes', () => {
|
|
expect(source).toContain("app.use('/oauth', preAuthTenantMiddleware, routes.oauth);");
|
|
expect(source).toContain("app.use('/api/auth', preAuthTenantMiddleware, routes.auth);");
|
|
expect(source).toContain(
|
|
"app.use('/api/config', preAuthTenantMiddleware, optionalJwtAuth, routes.config);",
|
|
);
|
|
expect(source).toContain("app.use('/api/share', preAuthTenantMiddleware, routes.share);");
|
|
});
|
|
});
|