441 lines
13 KiB
JavaScript
441 lines
13 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import {
|
|
bootstrapPortalGatewayServices,
|
|
createPortalRunDeliverablesValidator,
|
|
} from './portal-gateway-services-bootstrap.mjs';
|
|
|
|
function createSetup(overrides = {}) {
|
|
const calls = [];
|
|
const pool = { id: 'pool' };
|
|
const userAuth = { id: 'user-auth' };
|
|
const sessionAccess = { id: 'session-access' };
|
|
const llmProviderService = { id: 'llm' };
|
|
const memoryV2 = {
|
|
async observePersonalMemory(options) {
|
|
calls.push(['observe-memory', options]);
|
|
},
|
|
};
|
|
const tkmindProxy = { id: 'proxy' };
|
|
const toolGateway = { id: 'tool-gateway' };
|
|
const agentRunGateway = { id: 'agent-run-gateway' };
|
|
let proxyOptions;
|
|
let toolOptions;
|
|
let agentOptions;
|
|
let validatorOptions;
|
|
const options = {
|
|
pool,
|
|
h5Root: '/app',
|
|
env: {
|
|
MEMIND_AGENT_RUN_AUTODISPATCH: 'yes',
|
|
MEMIND_AGENT_RUN_QUEUE_CONCURRENCY: '3',
|
|
MEMIND_AGENT_RUN_TIMEOUT_MS: '9000',
|
|
},
|
|
apiTarget: 'http://primary',
|
|
apiTargets: ['http://primary', 'http://secondary'],
|
|
apiSecret: 'secret',
|
|
userAuth,
|
|
sessionAccess,
|
|
sessionStreamStore: { id: 'stream-store' },
|
|
llmProviderService,
|
|
subscriptionService: { id: 'subscription' },
|
|
sessionSnapshotService: { id: 'snapshot' },
|
|
conversationMemoryService: {
|
|
id: 'conversation-memory',
|
|
},
|
|
memoryV2,
|
|
systemDisclosurePolicyService: {
|
|
id: 'system-disclosure-policy',
|
|
},
|
|
mindSpaceAssets: {
|
|
async readAsset(userId, assetId) {
|
|
calls.push(['read-asset', userId, assetId]);
|
|
return {
|
|
asset: { mimeType: 'image/png' },
|
|
path: '/assets/image.png',
|
|
};
|
|
},
|
|
},
|
|
directChatService: { id: 'direct-chat' },
|
|
chatIntentRouter: { id: 'intent-router' },
|
|
async syncUserGeneratedPages(userId, options) {
|
|
calls.push(['sync-pages', userId, options]);
|
|
return { synced: true };
|
|
},
|
|
isSessionPageDeliveryActive(sessionId) {
|
|
calls.push(['delivery-active', sessionId]);
|
|
return sessionId === 'busy-session';
|
|
},
|
|
createTkmindProxyFn(receivedOptions) {
|
|
calls.push(['proxy']);
|
|
proxyOptions = receivedOptions;
|
|
return tkmindProxy;
|
|
},
|
|
createToolGatewayFn(receivedOptions) {
|
|
calls.push(['tool-gateway']);
|
|
toolOptions = receivedOptions;
|
|
return toolGateway;
|
|
},
|
|
createAgentRunGatewayFn(receivedOptions) {
|
|
calls.push(['agent-run-gateway']);
|
|
agentOptions = receivedOptions;
|
|
return agentRunGateway;
|
|
},
|
|
createRunDeliverablesValidatorFn(receivedOptions) {
|
|
calls.push(['validator']);
|
|
validatorOptions = receivedOptions;
|
|
return async () => ({ errors: [] });
|
|
},
|
|
async readAssetFileFn(assetPath) {
|
|
calls.push(['read-asset-file', assetPath]);
|
|
return Buffer.from('image');
|
|
},
|
|
...overrides,
|
|
};
|
|
return {
|
|
calls,
|
|
options,
|
|
pool,
|
|
userAuth,
|
|
sessionAccess,
|
|
llmProviderService,
|
|
memoryV2,
|
|
tkmindProxy,
|
|
toolGateway,
|
|
agentRunGateway,
|
|
getCaptured() {
|
|
return {
|
|
proxyOptions,
|
|
toolOptions,
|
|
agentOptions,
|
|
validatorOptions,
|
|
};
|
|
},
|
|
};
|
|
}
|
|
|
|
test('requires the gateway dependencies', () => {
|
|
assert.throws(
|
|
() => bootstrapPortalGatewayServices(),
|
|
/requires gateway dependencies/,
|
|
);
|
|
});
|
|
|
|
test('preserves Proxy, Tool, and Agent gateway wiring', () => {
|
|
const setup = createSetup();
|
|
const result = bootstrapPortalGatewayServices(
|
|
setup.options,
|
|
);
|
|
const captured = setup.getCaptured();
|
|
|
|
assert.deepEqual(
|
|
setup.calls.map(([name]) => name),
|
|
['proxy', 'tool-gateway', 'validator', 'agent-run-gateway'],
|
|
);
|
|
assert.equal(result.tkmindProxy, setup.tkmindProxy);
|
|
assert.equal(result.toolGateway, setup.toolGateway);
|
|
assert.equal(
|
|
result.agentRunGateway,
|
|
setup.agentRunGateway,
|
|
);
|
|
assert.equal(captured.proxyOptions.userAuth, setup.userAuth);
|
|
assert.equal(
|
|
captured.proxyOptions.sessionAccess,
|
|
setup.sessionAccess,
|
|
);
|
|
assert.equal(
|
|
captured.proxyOptions.llmProviderService,
|
|
setup.llmProviderService,
|
|
);
|
|
assert.equal(
|
|
captured.proxyOptions.systemDisclosurePolicyService,
|
|
setup.options.systemDisclosurePolicyService,
|
|
);
|
|
assert.deepEqual(captured.toolOptions, {
|
|
llmProviderService: setup.llmProviderService,
|
|
});
|
|
assert.deepEqual(captured.validatorOptions, {
|
|
h5Root: '/app',
|
|
});
|
|
assert.equal(captured.agentOptions.tkmindProxy, setup.tkmindProxy);
|
|
assert.equal(captured.agentOptions.toolGateway, setup.toolGateway);
|
|
assert.equal(
|
|
captured.agentOptions.systemDisclosurePolicyService,
|
|
setup.options.systemDisclosurePolicyService,
|
|
);
|
|
assert.equal(captured.agentOptions.autoDispatch, true);
|
|
assert.equal(captured.agentOptions.maxConcurrentRuns, 3);
|
|
assert.equal(captured.agentOptions.runTimeoutMs, 9000);
|
|
assert.equal(captured.agentOptions.observeWorkflowRun, null);
|
|
assert.equal(captured.agentOptions.observeWorkflowValidation, null);
|
|
assert.equal(captured.agentOptions.enforcePageDataWorkflowValidation, false);
|
|
assert.equal(captured.agentOptions.maxConcurrentShadowObservations, 2);
|
|
assert.equal(captured.agentOptions.maxQueuedShadowObservations, 100);
|
|
});
|
|
|
|
test('wires Shadow observation only behind the explicit environment gate', () => {
|
|
const configService = { id: 'orchestrator-config' };
|
|
const observer = async () => ({ observed: false });
|
|
const validationObserver = async () => ({ observed: false });
|
|
observer.observeValidation = validationObserver;
|
|
let receivedObserverOptions = null;
|
|
const setup = createSetup({
|
|
env: {
|
|
MEMIND_ORCHESTRATOR_SHADOW_OBSERVATION_ENABLED: '1',
|
|
MEMIND_ORCHESTRATOR_SERVICE_TOKEN: 'shadow-token',
|
|
MEMIND_ORCHESTRATOR_SHADOW_MAX_CONCURRENCY: '4',
|
|
MEMIND_ORCHESTRATOR_SHADOW_MAX_QUEUE: '25',
|
|
MEMIND_ORCHESTRATOR_PAGE_DATA_VALIDATION_GATE_ENABLED: '1',
|
|
},
|
|
createOrchestratorAdminConfigServiceFn(receivedPool) {
|
|
assert.equal(receivedPool, setup.pool);
|
|
return configService;
|
|
},
|
|
createWorkflowShadowObserverFn(options) {
|
|
receivedObserverOptions = options;
|
|
return observer;
|
|
},
|
|
});
|
|
|
|
bootstrapPortalGatewayServices(setup.options);
|
|
const { agentOptions } = setup.getCaptured();
|
|
assert.equal(receivedObserverOptions.configService, configService);
|
|
assert.equal(receivedObserverOptions.serviceToken, 'shadow-token');
|
|
assert.equal(agentOptions.observeWorkflowRun, observer);
|
|
assert.equal(agentOptions.observeWorkflowValidation, validationObserver);
|
|
assert.equal(agentOptions.enforcePageDataWorkflowValidation, true);
|
|
assert.equal(agentOptions.maxConcurrentShadowObservations, 4);
|
|
assert.equal(agentOptions.maxQueuedShadowObservations, 25);
|
|
});
|
|
|
|
test('keeps the Page Data validation gate disabled without Shadow wiring', () => {
|
|
const setup = createSetup({
|
|
env: {
|
|
MEMIND_ORCHESTRATOR_PAGE_DATA_VALIDATION_GATE_ENABLED: '1',
|
|
},
|
|
});
|
|
|
|
bootstrapPortalGatewayServices(setup.options);
|
|
|
|
const { agentOptions } = setup.getCaptured();
|
|
assert.equal(agentOptions.observeWorkflowValidation, null);
|
|
assert.equal(agentOptions.enforcePageDataWorkflowValidation, false);
|
|
});
|
|
|
|
test('preserves local asset reads and optional asset absence', async () => {
|
|
const setup = createSetup();
|
|
bootstrapPortalGatewayServices(setup.options);
|
|
const { proxyOptions } = setup.getCaptured();
|
|
|
|
assert.deepEqual(
|
|
await proxyOptions.localFetchAsset('user-1', 'asset-1'),
|
|
{
|
|
buffer: Buffer.from('image'),
|
|
mimeType: 'image/png',
|
|
},
|
|
);
|
|
assert.deepEqual(
|
|
setup.calls.slice(-2),
|
|
[
|
|
['read-asset', 'user-1', 'asset-1'],
|
|
['read-asset-file', '/assets/image.png'],
|
|
],
|
|
);
|
|
|
|
const withoutAssets = createSetup({
|
|
mindSpaceAssets: null,
|
|
});
|
|
bootstrapPortalGatewayServices(withoutAssets.options);
|
|
assert.equal(
|
|
withoutAssets.getCaptured().proxyOptions.localFetchAsset,
|
|
null,
|
|
);
|
|
});
|
|
|
|
test('preserves memory, page sync, and busy callbacks', async () => {
|
|
const setup = createSetup();
|
|
bootstrapPortalGatewayServices(setup.options);
|
|
const { agentOptions } = setup.getCaptured();
|
|
|
|
await agentOptions.observePersonalMemoryOnSuccess({
|
|
userId: 'user-1',
|
|
sessionId: 'session-1',
|
|
userMessage: { role: 'user', content: 'remember' },
|
|
});
|
|
assert.deepEqual(setup.calls.at(-1), [
|
|
'observe-memory',
|
|
{
|
|
userId: 'user-1',
|
|
sessionId: 'session-1',
|
|
messages: [
|
|
{ role: 'user', content: 'remember' },
|
|
],
|
|
},
|
|
]);
|
|
assert.deepEqual(
|
|
await agentOptions.syncUserPagesOnSuccess({
|
|
userId: 'user-1',
|
|
sessionId: 'session-1',
|
|
runStartedAtMs: 123,
|
|
}),
|
|
{ synced: true },
|
|
);
|
|
assert.equal(
|
|
agentOptions.isSessionExternallyBusy({
|
|
sessionId: 'busy-session',
|
|
}),
|
|
true,
|
|
);
|
|
});
|
|
|
|
test('passive candidate runtime disables the singleton Agent recovery loop', () => {
|
|
let recoveryStarts = 0;
|
|
const setup = createSetup({
|
|
env: {
|
|
MEMIND_RUNTIME_ROLE: 'candidate',
|
|
MEMIND_CANARY_PASSIVE_RUNTIME: '1',
|
|
},
|
|
startAgentRunRecoveryLoopFn() {
|
|
recoveryStarts += 1;
|
|
return { id: 'recovery' };
|
|
},
|
|
});
|
|
|
|
const result = bootstrapPortalGatewayServices(setup.options);
|
|
|
|
assert.equal(recoveryStarts, 0);
|
|
assert.equal(result.agentRunRecoveryTimer, null);
|
|
});
|
|
|
|
test('keeps memory observation optional and parses disabled dispatch', async () => {
|
|
const setup = createSetup({
|
|
env: {
|
|
MEMIND_AGENT_RUN_AUTODISPATCH: 'off',
|
|
},
|
|
memoryV2: {},
|
|
});
|
|
bootstrapPortalGatewayServices(setup.options);
|
|
const { agentOptions } = setup.getCaptured();
|
|
|
|
await agentOptions.observePersonalMemoryOnSuccess({
|
|
userId: 'user-1',
|
|
sessionId: 'session-1',
|
|
userMessage: 'hello',
|
|
});
|
|
assert.equal(agentOptions.autoDispatch, false);
|
|
assert.equal(agentOptions.maxConcurrentRuns, 1);
|
|
assert.equal(agentOptions.runTimeoutMs, 15 * 60 * 1000);
|
|
});
|
|
|
|
test('validates Page Data issues, policy grants, and browser storage', async () => {
|
|
const policyChecks = [];
|
|
const validator = createPortalRunDeliverablesValidator({
|
|
h5Root: '/app',
|
|
resolveMindSpaceUserPublishDirFn: () =>
|
|
'/publish/user-1',
|
|
normalizeWorkspaceRelativePathFn: (value) => value,
|
|
resolvePathFn: (...parts) => parts.join('/').replaceAll('//', '/'),
|
|
pathSeparator: '/',
|
|
existsSyncFn: () => true,
|
|
readFileSyncFn: () => '<html>page data</html>',
|
|
evaluatePageDataHtmlContentFn: () => ({
|
|
usesPageDataApi: true,
|
|
issues: ['dataset_binding_missing'],
|
|
}),
|
|
readPageAccessPolicyFn: () => ({ id: 'policy' }),
|
|
detectPageDataDatasetUsageFromHtmlFn: () => [
|
|
[
|
|
'orders',
|
|
{ read: true, insert: true },
|
|
],
|
|
],
|
|
policyAllowsActionFn(policy, dataset, action) {
|
|
policyChecks.push([policy, dataset, action]);
|
|
return action === 'read';
|
|
},
|
|
scanWorkspaceFilesForProhibitedBrowserStorageFn:
|
|
(options) => {
|
|
assert.deepEqual(options, {
|
|
publishDir: '/publish/user-1',
|
|
relativePaths: ['public/orders.html'],
|
|
});
|
|
return [
|
|
{
|
|
relativePath: 'public/orders.html',
|
|
apis: ['localStorage', 'indexedDB'],
|
|
},
|
|
];
|
|
},
|
|
});
|
|
|
|
const result = await validator({
|
|
userId: 'user-1',
|
|
deliverables: {
|
|
pages: [
|
|
{
|
|
pageId: 'page-1',
|
|
workspaceRelativePath: 'public/orders.html',
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
assert.deepEqual(policyChecks, [
|
|
[{ id: 'policy' }, 'orders', 'read'],
|
|
[{ id: 'policy' }, 'orders', 'insert'],
|
|
]);
|
|
assert.deepEqual(
|
|
result.errors.map(({ code }) => code),
|
|
[
|
|
'dataset_binding_missing',
|
|
'page_data_policy_action_missing',
|
|
'browser_storage_forbidden',
|
|
],
|
|
);
|
|
});
|
|
|
|
test('skips non-public, missing, and path-escaping Page Data files', async () => {
|
|
const readPaths = [];
|
|
const validator = createPortalRunDeliverablesValidator({
|
|
h5Root: '/app',
|
|
resolveMindSpaceUserPublishDirFn: () =>
|
|
'/publish/user-1',
|
|
normalizeWorkspaceRelativePathFn: (value) => value,
|
|
resolvePathFn: (...parts) => {
|
|
const joined = parts.join('/');
|
|
if (joined.includes('escape')) return '/outside/file.html';
|
|
return joined.replaceAll('//', '/');
|
|
},
|
|
pathSeparator: '/',
|
|
existsSyncFn: (filePath) =>
|
|
!filePath.includes('missing'),
|
|
readFileSyncFn(filePath) {
|
|
readPaths.push(filePath);
|
|
return '<html></html>';
|
|
},
|
|
evaluatePageDataHtmlContentFn: () => ({
|
|
usesPageDataApi: false,
|
|
issues: [],
|
|
}),
|
|
scanWorkspaceFilesForProhibitedBrowserStorageFn:
|
|
() => [],
|
|
});
|
|
|
|
const result = await validator({
|
|
userId: 'user-1',
|
|
deliverables: {
|
|
pages: [
|
|
{ workspaceRelativePath: 'private/a.html' },
|
|
{ workspaceRelativePath: 'public/missing.html' },
|
|
{ workspaceRelativePath: 'public/escape.html' },
|
|
{ workspaceRelativePath: 'public/valid.html' },
|
|
],
|
|
},
|
|
});
|
|
|
|
assert.deepEqual(readPaths, [
|
|
'/publish/user-1/public/valid.html',
|
|
]);
|
|
assert.deepEqual(result, { errors: [] });
|
|
});
|