merge: integrate AgentBus account workers
This commit is contained in:
commit
b5f58477d9
30 files changed
+1054
-257
No files matched your search
@@ -55,6 +55,32 @@ test('business authorization migration adds a fail-closed per-user allowlist for
|
||||
assert.doesNotMatch(sql, /INSERT INTO user_business_route_authorizations[\s\S]+SELECT[\s\S]+FROM users/i);
|
||||
});
|
||||
|
||||
test('AgentBus account-worker migration adds fail-closed channel, task, browser, and ERP identity ownership', async () => {
|
||||
const sql = await source('../migrations/018_agentbus_account_workers.sql');
|
||||
assert.match(sql, /ADD COLUMN IF NOT EXISTS erp_account text/);
|
||||
assert.match(sql, /users_org_erp_account_unique_idx/);
|
||||
assert.match(sql, /ADD COLUMN IF NOT EXISTS owner_user_id uuid/);
|
||||
assert.match(sql, /FOREIGN KEY \(organization_id, owner_user_id\)[\s\S]+REFERENCES users \(organization_id, id\)/);
|
||||
assert.match(sql, /user_channels_owner_unique_idx/);
|
||||
assert.match(sql, /ADD COLUMN IF NOT EXISTS assigned_user_id uuid/);
|
||||
assert.match(sql, /FOREIGN KEY \(organization_id, assigned_user_id\)[\s\S]+REFERENCES users \(organization_id, id\)/);
|
||||
assert.match(sql, /source = 'manual'[\s\S]+created_by IS NOT NULL/);
|
||||
assert.match(sql, /erp_account_verified boolean NOT NULL DEFAULT false/);
|
||||
assert.match(sql, /browser_connections_active_user_unique_idx/);
|
||||
assert.match(sql, /status = 'superseded'/);
|
||||
assert.match(sql, /owner_user_id IS NULL[\s\S]+enabled = true/);
|
||||
});
|
||||
|
||||
test('AgentBus channel keys and owners are unique so one inbound identity cannot fan out to multiple employees', async () => {
|
||||
const channels = await source('../src/agentbus-channels.ts');
|
||||
assert.match(channels, /requireAssignableOwner/);
|
||||
assert.match(channels, /requireUniqueAgentBusKey/);
|
||||
assert.match(channels, /pg_advisory_xact_lock/);
|
||||
assert.match(channels, /sha256Text\(agentbusKey\)/);
|
||||
assert.match(channels, /channel_key_conflict/);
|
||||
assert.match(channels, /channel_owner_conflict/);
|
||||
});
|
||||
|
||||
test('account lifecycle is administrator-gated and protects passwords, sessions, and the last administrator', async () => {
|
||||
const [auth, server] = await Promise.all([
|
||||
source('../src/auth.ts'),
|
||||
@@ -71,6 +97,9 @@ test('account lifecycle is administrator-gated and protects passwords, sessions,
|
||||
assert.doesNotMatch(auth, /password\.length < 12|12—512/);
|
||||
assert.match(auth, /account\.password_reset/);
|
||||
assert.match(auth, /account\.password_changed/);
|
||||
assert.match(auth, /function validateAccountRouting/);
|
||||
assert.match(auth, /admin_erp_account_forbidden/);
|
||||
assert.match(auth, /erp_account_conflict/);
|
||||
assert.match(server, /app\.get\('\/api\/accounts'[\s\S]+requireAdminSession\(request\)/);
|
||||
assert.match(server, /app\.post\('\/api\/accounts'[\s\S]+requireAdminMutationSession\(request\)/);
|
||||
assert.match(server, /app\.get\('\/api\/audit'[\s\S]+requireAdminSession\(request\)/);
|
||||
@@ -183,7 +212,7 @@ test('ordinary task access is enforced across reads, mutations, artifacts, event
|
||||
source('../src/task-service.ts'),
|
||||
source('../src/server.ts')
|
||||
]);
|
||||
assert.match(tasks, /created_by = \$4 AND source = 'manual'/);
|
||||
assert.match(tasks, /assigned_user_id = \$4/);
|
||||
assert.match(tasks, /private async lockTaskForAccess/);
|
||||
for (const mutation of ['reparseTaskWithAi', 'confirmTask', 'claimForBrowser', 'recordExecutionResult', 'cancelTask']) {
|
||||
const start = tasks.indexOf(`async ${mutation}(`);
|
||||
@@ -191,11 +220,19 @@ test('ordinary task access is enforced across reads, mutations, artifacts, event
|
||||
const body = tasks.slice(start, start + 20_000);
|
||||
assert.match(body, /lockTaskForAccess\(/, `${mutation} uses the task access lock`);
|
||||
}
|
||||
assert.match(tasks, /async getTaskArtifact[\s\S]+created_by = \$4 AND source = 'manual'/);
|
||||
assert.match(tasks, /async eventsSince[\s\S]+t\.created_by = \$4 AND t\.source = 'manual'/);
|
||||
assert.match(tasks, /async getTaskArtifact[\s\S]+assigned_user_id = \$4/);
|
||||
assert.match(tasks, /async eventsSince[\s\S]+t\.assigned_user_id = \$4/);
|
||||
assert.match(tasks, /async getTaskInputHistory[\s\S]+actor_user_id/);
|
||||
assert.match(tasks, /WHERE organization_id = \$1 AND user_id = \$2 AND connection_id = \$3/);
|
||||
assert.match(tasks, /connection\.organization_id = \$1[\s\S]+connection\.user_id = \$2[\s\S]+connection\.connection_id = \$3/);
|
||||
assert.match(tasks, /WHERE browser_connections\.user_id = EXCLUDED\.user_id/);
|
||||
assert.match(tasks, /browser_worker_conflict/);
|
||||
assert.match(tasks, /identity_mismatch/);
|
||||
assert.match(tasks, /erp_account_mismatch/);
|
||||
assert.match(tasks, /task_execution_assignee_mismatch/);
|
||||
assert.match(tasks, /assignee: publicActor\(row\.assignee_id, row\.assignee_username\)/);
|
||||
const confirmation = tasks.slice(tasks.indexOf('async confirmTask('), tasks.indexOf('async claimForBrowser('));
|
||||
assert.match(confirmation, /assigned_user_id/);
|
||||
assert.match(confirmation, /task_execution_assignee_mismatch/);
|
||||
assert.match(tasks, /i\.actor_user_id IS NOT DISTINCT FROM \$3::uuid/);
|
||||
assert.match(tasks, /private async lockIdempotencyKey/);
|
||||
assert.match(tasks, /pg_advisory_xact_lock/);
|
||||
@@ -215,6 +252,8 @@ test('operator UI exposes role-aware accounts, executive drill-through, original
|
||||
assert.match(index, /href="\/operations-dashboard"/);
|
||||
assert.match(index, /id="passwordChangeForm"/);
|
||||
assert.match(index, /id="accountForm"/);
|
||||
assert.match(index, /id="accountErpAccount"/);
|
||||
assert.match(index, /id="channelOwnerUserId"/);
|
||||
assert.doesNotMatch(index, /accountMustChangePassword|首次登录必须修改密码|minlength="12"|12—512/);
|
||||
assert.match(index, /id="accountAuthorizationPanel"/);
|
||||
assert.match(index, /id="accountAuthorizationTypes"/);
|
||||
@@ -240,6 +279,11 @@ test('operator UI exposes role-aware accounts, executive drill-through, original
|
||||
assert.match(app, /\/api\/tasks\/\$\{encodeURIComponent\(taskId\)\}\/input-history/);
|
||||
assert.match(app, /创建人与原始输入审计/);
|
||||
assert.match(app, /function renderAccountAuthorizationPanel/);
|
||||
assert.match(app, /function renderChannelOwnerOptions/);
|
||||
assert.match(app, /function taskAssignedToCurrentAccount/);
|
||||
assert.match(app, /expected_erp_account/);
|
||||
assert.match(app, /executable_by=me/);
|
||||
assert.match(app, /const candidates = Array\.isArray\(result\.tasks\) \? result\.tasks : \[\]/);
|
||||
assert.match(app, /\/business-authorizations/);
|
||||
assert.match(app, /当前默认不能执行任何业务/);
|
||||
assert.match(app, /function canViewOperationsDashboard/);
|
||||
|
||||
@@ -145,6 +145,11 @@ function publicChannelFixture(overrides: Partial<PublicAgentBusChannel> = {}): P
|
||||
display_name: '外部用户 A',
|
||||
external_user_ref: 'external-user-a',
|
||||
agentbus_bot_address: 'bot:test:listener',
|
||||
owner_user_id: 'user-a',
|
||||
owner_username: 'employee-a',
|
||||
owner_role: 'user',
|
||||
owner_erp_account: 'ERP-A',
|
||||
routing_ready: true,
|
||||
enabled: true,
|
||||
status: 'disabled',
|
||||
key_configured: true,
|
||||
@@ -1199,7 +1204,9 @@ test('durable channel listener persists route and resends accepted/result delive
|
||||
displayName: '外部用户 A',
|
||||
wsUrl: 'wss://mesh.nianxx.cn/ws',
|
||||
wsToken: 'channel-ws-token',
|
||||
botAddress: 'bot:channel-a:listener'
|
||||
botAddress: 'bot:channel-a:listener',
|
||||
ownerUserId: 'user-a',
|
||||
ownerRole: 'user'
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1228,6 +1235,8 @@ test('durable channel listener persists route and resends accepted/result delive
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
assert.equal(receivedContext.channelId, 'channel-1');
|
||||
assert.equal(receivedContext.userId, 'user-a');
|
||||
assert.equal(receivedContext.role, 'user');
|
||||
assert.equal(receivedInput.channelId, 'channel-1');
|
||||
assert.deepEqual(receivedInput.agentBusRoute, {
|
||||
inboundFrameId: 'channel-durable-1',
|
||||
@@ -1399,7 +1408,9 @@ test('durable roster lifecycle assigns the final result only to the attachment f
|
||||
displayName: '外部用户 A',
|
||||
wsUrl: 'wss://mesh.nianxx.cn/ws',
|
||||
wsToken: 'channel-ws-token',
|
||||
botAddress: 'bot:channel-a:listener'
|
||||
botAddress: 'bot:channel-a:listener',
|
||||
ownerUserId: 'user-a',
|
||||
ownerRole: 'user'
|
||||
}
|
||||
});
|
||||
t.after(() => listener.stop());
|
||||
@@ -1555,7 +1566,9 @@ test('durable channel persists and sends an attachment rejection result while th
|
||||
displayName: '外部用户 A',
|
||||
wsUrl: 'wss://mesh.nianxx.cn/ws',
|
||||
wsToken: 'channel-ws-token',
|
||||
botAddress: 'bot:channel-a:listener'
|
||||
botAddress: 'bot:channel-a:listener',
|
||||
ownerUserId: 'user-a',
|
||||
ownerRole: 'user'
|
||||
},
|
||||
logger: {
|
||||
info(metadata, message) {
|
||||
|
||||
@@ -47,18 +47,18 @@ test('message routing starts a new session for a business directive, not for a s
|
||||
|
||||
test('task access contract isolates users and team leads while preserving administrator and worker access', () => {
|
||||
const cases = [
|
||||
{ name: 'administrator sees another user manual task', access: { userId: 'admin', role: 'admin' as const }, createdBy: 'user-a', source: 'manual' as const, allowed: true },
|
||||
{ name: 'trusted worker sees AgentBus task', access: { userId: '', role: undefined }, createdBy: null, source: 'agentbus' as const, allowed: true },
|
||||
{ name: 'team lead sees own manual task', access: { userId: 'lead-a', role: 'team_lead' as const }, createdBy: 'lead-a', source: 'manual' as const, allowed: true },
|
||||
{ name: 'team lead cannot use the normal task path for another manual task', access: { userId: 'lead-a', role: 'team_lead' as const }, createdBy: 'user-b', source: 'manual' as const, allowed: false },
|
||||
{ name: 'team lead cannot use the normal task path for AgentBus work', access: { userId: 'lead-a', role: 'team_lead' as const }, createdBy: 'lead-a', source: 'agentbus' as const, allowed: false },
|
||||
{ name: 'ordinary user sees own manual task', access: { userId: 'user-a', role: 'user' as const }, createdBy: 'user-a', source: 'manual' as const, allowed: true },
|
||||
{ name: 'ordinary user cannot see another manual task', access: { userId: 'user-a', role: 'user' as const }, createdBy: 'user-b', source: 'manual' as const, allowed: false },
|
||||
{ name: 'ordinary user cannot see AgentBus task', access: { userId: 'user-a', role: 'user' as const }, createdBy: 'user-a', source: 'agentbus' as const, allowed: false },
|
||||
{ name: 'ordinary user without an actor cannot see a task', access: { userId: '', role: 'user' as const }, createdBy: '', source: 'manual' as const, allowed: false }
|
||||
{ name: 'administrator can inspect another assigned task', access: { userId: 'admin', role: 'admin' as const }, assignedUserId: 'user-a', allowed: true },
|
||||
{ name: 'trusted worker can inspect an unassigned task', access: { userId: '', role: undefined }, assignedUserId: null, allowed: true },
|
||||
{ name: 'team lead sees own manual task', access: { userId: 'lead-a', role: 'team_lead' as const }, assignedUserId: 'lead-a', allowed: true },
|
||||
{ name: 'team lead cannot use the normal task path for another task', access: { userId: 'lead-a', role: 'team_lead' as const }, assignedUserId: 'user-b', allowed: false },
|
||||
{ name: 'team lead sees own AgentBus work', access: { userId: 'lead-a', role: 'team_lead' as const }, assignedUserId: 'lead-a', allowed: true },
|
||||
{ name: 'ordinary user sees own manual task', access: { userId: 'user-a', role: 'user' as const }, assignedUserId: 'user-a', allowed: true },
|
||||
{ name: 'ordinary user cannot see another task', access: { userId: 'user-a', role: 'user' as const }, assignedUserId: 'user-b', allowed: false },
|
||||
{ name: 'ordinary user sees own AgentBus task', access: { userId: 'user-a', role: 'user' as const }, assignedUserId: 'user-a', allowed: true },
|
||||
{ name: 'ordinary user without an actor cannot see a task', access: { userId: '', role: 'user' as const }, assignedUserId: '', allowed: false }
|
||||
];
|
||||
for (const item of cases) {
|
||||
assert.equal(canAccessTask(item.access, { createdBy: item.createdBy, source: item.source }), item.allowed, item.name);
|
||||
assert.equal(canAccessTask(item.access, { assignedUserId: item.assignedUserId }), item.allowed, item.name);
|
||||
}
|
||||
assert.equal(isTaskOwnerRestricted('admin'), false);
|
||||
assert.equal(isTaskOwnerRestricted('team_lead'), true);
|
||||
@@ -86,8 +86,14 @@ test('business route authorization is an explicit allowlist for team leads and o
|
||||
role: 'user', source: 'manual', routeId: null, authorizedRouteIds: [routeId]
|
||||
}), false, 'unclassified manual input fails closed for non-administrators');
|
||||
assert.equal(canExecuteBusinessRoute({
|
||||
role: undefined, source: 'agentbus', routeId: null, authorizedRouteIds: []
|
||||
}), true, 'trusted AgentBus intake retains its separate administrator-controlled boundary');
|
||||
role: 'user', source: 'agentbus', routeId, authorizedRouteIds: [routeId]
|
||||
}), true, 'bound AgentBus intake uses the employee route allowlist');
|
||||
assert.equal(canExecuteBusinessRoute({
|
||||
role: undefined, source: 'agentbus', routeId, authorizedRouteIds: [routeId]
|
||||
}), false, 'unbound AgentBus intake fails closed');
|
||||
assert.equal(canExecuteBusinessRoute({
|
||||
role: 'admin', source: 'agentbus', routeId, authorizedRouteIds: [routeId]
|
||||
}), false, 'administrators cannot be AgentBus execution owners');
|
||||
});
|
||||
|
||||
test('field encryption round-trips without storing plaintext', () => {
|
||||
@@ -311,7 +317,7 @@ test('control plane requires the latest durable task-outcome migration before re
|
||||
const { readFile } = await import('node:fs/promises');
|
||||
const db = await readFile(new URL('../src/db.ts', import.meta.url), 'utf8');
|
||||
const server = await readFile(new URL('../src/server.ts', import.meta.url), 'utf8');
|
||||
assert.equal(REQUIRED_SCHEMA_VERSION, '017_user_business_route_authorizations');
|
||||
assert.equal(REQUIRED_SCHEMA_VERSION, '018_agentbus_account_workers');
|
||||
assert.match(db, /schema_migrations/);
|
||||
assert.match(db, /databaseReadiness/);
|
||||
assert.match(db, /assertDatabaseSchema/);
|
||||
@@ -1226,7 +1232,7 @@ test('operator page has a login gate and uses the durable task API', async () =>
|
||||
assert.match(index, /id="loginPanel"/);
|
||||
assert.match(index, /id="workbench"[^>]*hidden/);
|
||||
assert.match(index, /styles\.css\?v=20260902-dashboard-mobile-share-1/);
|
||||
assert.match(index, /app\.js\?v=20260902-dashboard-filter-fix-2/);
|
||||
assert.match(index, /app\.js\?v=20260902-agentbus-dashboard-filter-1/);
|
||||
assert.match(index, /id="statusDetailsPopover"/);
|
||||
assert.match(index, /id="statusDetailsRefresh"/);
|
||||
assert.match(app, /apiRequest\(`\/api\/tasks\?\$\{params\.toString\(\)\}`/);
|
||||
@@ -1430,7 +1436,7 @@ test('operator page has a login gate and uses the durable task API', async () =>
|
||||
assert.doesNotMatch(app, /task-stage-index|task-stage-current/);
|
||||
assert.ok(app.indexOf('/claim') < app.indexOf("sendToExtension('CREATE_TASK'"), 'server claim must precede extension dispatch');
|
||||
assert.doesNotMatch(app, /syncPendingTasks|>重交</);
|
||||
assert.match(app, /runtimeTasks\(\)\.filter\(isTaskPollable\)/);
|
||||
assert.match(app, /runtimeTasks\(\)\.filter\(\(task\) => taskAssignedToCurrentAccount\(task\) && isTaskPollable\(task\)\)/);
|
||||
assert.match(app, /execution_id: executionId/);
|
||||
assert.doesNotMatch(bridge, /async function upsertBusinessTask/);
|
||||
assert.match(bridge, /task: currentBusinessTask/);
|
||||
|
||||
Reference in new issue
Block a user