Files
AgentLab/scripts/check-capabilities.mjs
T

347 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 工具页的「能力」「端点」页签和设置页的「身份」「通知」「审计」的检查(应用平台 A6、A7)。
//
// 守的是:
// - 能力列表标出读写效果,同步得出和治理后不一致时标出来;治理带 version 保存(乐观锁),409 时载入最新的、不覆盖;
// - 端点的认证头只显示掩码,提交掩码值表示保持原值;允许访问内网只有管理员能开;测试连接按环境发;
// - 关登录(默认)时所有人等同管理员:治理、退役、身份设置都能改;
// - 设置页的身份、通知、审计三个页签在,通知渠道的地址只显示主机名;
// - 能力字段的待审阅(D-i)复用数据目录的审阅队列:成批确认逐项带能力的治理版本;
// - 自定义工具可以绑定登记的端点(只填路径);MCP 的认证头按掩码编辑;本地子进程只有管理员能建、能改(A6);
// - 单点登录映射管理员:用户或组至少一项;开了登录的使用者在设置页只剩个人的主题和通知,换主题不写服务端(D-s)。
//
// 不写库:/api/capabilities、/api/endpoints、/api/auth、/api/users、/api/groups、/api/audit、/api/notify 全部伪造,
// 伪造的接口改一份内存里的数据。其余写请求一律 409 并记账,检查结束时必须是空的。
//
// 跑之前前端得起着(./scripts/dev.sh),默认连 5273:
// AGENTLAB_WEB=http://localhost:<前端端口> node scripts/check-capabilities.mjs
import { chromium } from '../frontend/node_modules/playwright-core/index.mjs'
const WEB = process.env.AGENTLAB_WEB ?? 'http://localhost:5273'
const CHROME = process.env.CHROME_PATH ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
const ONLY = (process.env.CAPABILITIES_ONLY ?? '').split(',').filter(Boolean)
let failed = 0
const check = (name, cond, detail = '') => {
const d = String(detail ?? '').replace(/\s+/g, ' ').slice(0, 160)
console.log(` ${cond ? '✓' : '✗'} ${name}${d ? ` — ${d}` : ''}`)
if (!cond) failed++
}
const opened = new Set()
async function section(name, fn) {
if (ONLY.length && !ONLY.some((k) => name.includes(k))) return
console.log(`=== ${name} ===`)
try {
await fn()
} catch (e) {
check(`${name} 中途出错`, false, String(e?.message ?? e).split('\n')[0])
} finally {
for (const c of opened) await c.close().catch(() => {})
opened.clear()
}
}
const waitFor = async (page, fn, ms = 5000) => {
for (let t = 0; t < ms; t += 100) {
if (await fn()) return true
await page.waitForTimeout(100)
}
return false
}
// ---------------------------------------------------------------- 夹具(通用示例名)
const cap = (name, extra = {}) => ({
name, system: 'erp', title: name, description: '', params_schema: { type: 'object', properties: {} }, returns_schema: null,
tabular: null, effect_system: 'read', effect: null, approval: 'inherit', unattended_write: false, fields: {}, masked: [],
credential_mode: 'service', endpoint_id: null, lookup: null, version: 1, missing: false, synced_at: '2026-10-10T02:00:00Z',
updated_by: null, ...extra,
})
const CAPS = [
cap('erp_query'),
cap('erp_pay', { effect_system: 'dynamic', effect: 'write', approval: 'confirm' }),
cap('mcp:crm/find', { system: 'crm', effect_system: 'read' }),
cap('db_query__shop', { system: 'shop', effect_system: 'dynamic' }),
]
const ENDPOINTS = [{
id: 'ep1', name: 'erp', description: '示例 ERP', idempotency_header: 'Idempotency-Key', timeout_s: 20, version: 3,
environments: {
prod: { base_url: 'https://erp.example.com/api', headers: { Authorization: 'Bearer ****1234' }, allow_intranet: false, verify_tls: true },
},
}]
// 形状同服务端 data/capability_review.review_queue:能力名、位置(指针、字段、写好的位置说法)、值、来源、状态、优先级、原因、运行次数、版本
const REVIEW = [
{ capability: 'erp_pay', system: 'erp', title: '付款', effect: 'write', pointer: '/amount', field: 'unit', field_text: '单位',
field_label: '返回字段 /amount 的单位', value: '元', display: '元', source: 'model', status: 'proposed', note: null, priority: 9,
reasons: ['写入能力', '被 12 次运行用过'], runs: 12, version: 4 },
{ capability: 'erp_query', system: 'erp', title: '查询', effect: 'read', pointer: '/items/*/name', field: 'label', field_text: '中文名',
field_label: '返回字段 /items/*/name 的中文名', value: '品名', display: '品名', source: 'naming', status: 'proposed', note: null,
priority: 3, reasons: ['被 3 次运行用过'], runs: 3, version: 2 },
]
const OFF = { mode: 'off', username: null, display_name: null, role: 'admin', groups: [], verified: false }
const MCP_ROWS = [
{ id: 'm1', name: 'files', transport: 'stdio', command: 'npx', args: ['-y', 'fs'], env: {}, url: null, enabled: true, headers: {},
endpoint_id: null, status: 'ok', last_error: null, tools_cache: [] },
{ id: 'm2', name: 'crm', transport: 'http', command: null, args: [], env: {}, url: 'https://crm.example.com/mcp', enabled: true,
headers: { Authorization: 'Bearer ****9a2c' }, endpoint_id: null, status: 'ok', last_error: null, tools_cache: [] },
]
async function open(path, { conflict = false, me = OFF } = {}) {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
opened.add(ctx)
const page = await ctx.newPage()
page.setDefaultTimeout(8000)
const errors = []
page.on('pageerror', (e) => errors.push(e.message))
const json = (route, body, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) })
const state = { caps: JSON.parse(JSON.stringify(CAPS)), endpoints: JSON.parse(JSON.stringify(ENDPOINTS)), governs: [], endpointWrites: [],
tests: [], syncs: 0, settings: [], reviews: [], batches: [], tools: [], mcp: [], prefs: [] }
await page.route(/\/api\//, (route) => {
const req = route.request()
if (req.method() === 'GET') return route.continue()
writes.push(`${req.method()} ${new URL(req.url()).pathname}`)
return json(route, { detail: '检查脚本不写库' }, 409)
})
await page.route(/\/api\/auth\/me$/, (route) => json(route, me))
await page.route(/\/api\/capabilities(\?.*)?$/, (route) => json(route, { items: state.caps }))
await page.route(/\/api\/capabilities\/review-queue(\?.*)?$/, (route) => {
const url = new URL(route.request().url())
state.reviews.push(url.search)
const name = url.searchParams.get('capability')
const items = REVIEW.filter((i) => !name || i.capability === name)
return json(route, { total: items.length, items })
})
await page.route(/\/api\/capabilities\/review\/batch$/, (route) => {
const body = route.request().postDataJSON()
state.batches.push(body)
return json(route, { results: body.items.map((i) => (i.capability === 'erp_query'
? { capability: i.capability, pointer: i.pointer, field: i.field, ok: false, version: null,
error: '能力「erp_query」的治理设置刚被修改过,请重新载入后再审阅', code: 'capability_conflict' }
: { capability: i.capability, pointer: i.pointer, field: i.field, ok: true, version: i.if_version + 1, error: null, code: null })) })
})
await page.route(/\/api\/custom-tools(\/[^/]+)?$/, (route) => {
const req = route.request()
if (req.method() === 'GET') return json(route, [])
state.tools.push({ method: req.method(), body: req.postDataJSON() })
return json(route, { id: 't1', ...req.postDataJSON() })
})
await page.route(/\/api\/mcp\/servers(\/[^/]+)?$/, (route) => {
const req = route.request()
if (req.method() === 'GET') return json(route, MCP_ROWS)
state.mcp.push({ method: req.method(), url: new URL(req.url()).pathname, body: req.postDataJSON() })
return json(route, { id: 'm9', ...req.postDataJSON() })
})
await page.route(/\/api\/settings$/, (route) => {
if (route.request().method() !== 'GET') { state.prefs.push(route.request().postDataJSON()); return json(route, {}) }
return me.mode !== 'off' && me.role === 'user' ? json(route, { detail: '没有权限' }, 403) : json(route, { ui: { theme: 'dark' } })
})
await page.route(/\/api\/capabilities\/sync$/, (route) => { state.syncs += 1; return json(route, { changed: 2 }) })
await page.route(/\/api\/capabilities\/[^/]+\/governance$/, (route) => {
const name = decodeURIComponent(new URL(route.request().url()).pathname.split('/')[3])
const body = route.request().postDataJSON()
state.governs.push({ name, body })
const cur = state.caps.find((c) => c.name === name)
if (conflict && state.governs.length === 1) {
cur.version += 1
cur.updated_by = '别人'
return json(route, { detail: '这个能力刚被其他人修改过' }, 409)
}
Object.assign(cur, body, { version: cur.version + 1 })
return json(route, cur)
})
await page.route(/\/api\/endpoints(\/[^/?]+)?(\/test)?(\?.*)?$/, (route) => {
const req = route.request()
const url = new URL(req.url())
const parts = url.pathname.split('/')
if (parts[4] === 'test') {
state.tests.push({ id: parts[3], env: url.searchParams.get('env') })
return json(route, { ok: true, status: 200, message: '' })
}
if (req.method() === 'GET') return json(route, { items: state.endpoints })
state.endpointWrites.push({ method: req.method(), body: req.postDataJSON?.() ?? null })
if (req.method() === 'PATCH') {
const ep = state.endpoints.find((e) => e.id === parts[3])
Object.assign(ep, req.postDataJSON(), { version: ep.version + 1 })
return json(route, ep)
}
return json(route, { ok: true })
})
await page.route(/\/api\/auth\/settings$/, (route) => {
if (route.request().method() === 'PUT') { state.settings.push(route.request().postDataJSON()); return json(route, { ok: true }) }
return json(route, { mode: 'off', env_locked: false, oidc: {}, user_can_chat: false })
})
await page.route(/\/api\/users$/, (route) => json(route, { items: [] }))
await page.route(/\/api\/groups$/, (route) => json(route, { items: [{ id: 'g1', name: 'finance', label: '财务', source: 'oidc', members: ['li'] }] }))
await page.route(/\/api\/audit(\?.*)?$/, (route) => json(route, { items: [
{ id: 'a1', at: '2026-10-10T02:00:00Z', actor: '张工', verified: false, action: 'capability.govern', subject_kind: 'capability',
subject_id: 'erp_pay', data: { version: 2 } }] }))
await page.route(/\/api\/notify\/channels$/, (route) => json(route, { items: [
{ id: 'ch1', name: '财务群', scope: 'global', owner_id: null, format: 'feishu', url_host: 'open.feishu.cn', signed: true,
events: ['task.opened'], enabled: true }] }))
await page.route(/\/api\/notify\/deliveries(\?.*)?$/, (route) => json(route, { items: [] }))
await page.goto(`${WEB}${path}`, { waitUntil: 'networkidle' })
await page.waitForTimeout(300)
return { page, state, errors }
}
const writes = []
const browser = await chromium.launch({ executablePath: CHROME })
// ================================================================
await section('能力列表:效果、筛选、同步', async () => {
const { page, state, errors } = await open('/tools/capabilities')
// 「能力」页签里还有「全部能力 / 待审阅」两个子页签(D-i),按名字整字匹配
check('工具页有「能力」页签', await page.getByRole('tab', { name: '能力', exact: true }).count() === 1,
(await page.locator('[role=tab]').allInnerTexts()).join(' | '))
check('列出全部能力', await page.locator('[data-capability]').count() === CAPS.length)
const pay = page.locator('[data-capability="erp_pay"] [data-capability-effect]')
check('治理后的效果和同步得出的不一致时标出来', (await pay.innerText()).includes('写入') && (await pay.innerText()).includes('*'))
check('没治理过的按同步得出的效果显示', (await page.locator('[data-capability="erp_query"] [data-capability-effect]').innerText()).includes('只读'))
await page.locator('[data-capability="mcp:crm/find"]').click()
// 右栏的治理设置是点开之后另取的:等它出来再判
check('能力名带斜杠也能打开(地址转义)', await waitFor(page, async () =>
await page.locator('[data-capability-editor="mcp:crm/find"]').count() === 1), page.url())
await page.locator('[data-capability-sync]').click()
check('立即同步', await waitFor(page, async () => state.syncs === 1))
check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | '))
})
await section('能力治理:带 version 保存;别人刚改过时载入最新的、不覆盖', async () => {
const { page, state } = await open('/tools/capabilities/erp_pay', { conflict: true })
const ed = page.locator('[data-capability-editor="erp_pay"]')
await ed.waitFor()
check('写入能力给无人值守写入的开关', await ed.locator('[data-capability-unattended]').count() === 1)
await ed.locator('[data-capability-masked]').fill('/data/items/*/phone')
await page.locator('[data-capability-save]').click()
check('保存带上读到的 version', await waitFor(page, async () => state.governs.length === 1) && state.governs[0].body.version === 1,
JSON.stringify(state.governs[0]?.body))
check('遮罩字段一行一个,写成列表', JSON.stringify(state.governs[0]?.body.masked) === JSON.stringify(['/data/items/*/phone']))
check('409:说明被别人改过,载入最新内容', await waitFor(page, async () => (await page.locator('body').innerText()).includes('刚被其他人修改过')))
check('载入的是最新版本', await waitFor(page, async () => (await page.locator('[data-capability-editor="erp_pay"]').innerText()).includes('v2')))
})
await section('端点:两个环境、认证头只显示掩码、测试连接', async () => {
const { page, state } = await open('/tools/endpoints')
const ep = page.locator('[data-endpoint="erp"]')
check('列出端点', await ep.count() === 1)
check('认证头只显示掩码', (await ep.locator('[data-endpoint-headers]').innerText()).includes('****1234'))
check('没有测试环境照实写', (await ep.locator('[data-endpoint-env="test"]').innerText()).includes('未配置'))
await ep.locator('[data-endpoint-test="prod"]').click()
check('测试连接按环境发', await waitFor(page, async () => state.tests.length === 1) && state.tests[0].env === 'prod', JSON.stringify(state.tests))
await ep.getByRole('button', { name: '编辑' }).click()
const ed = page.locator('[data-endpoint-editor]')
await ed.waitFor()
check('关登录时(所有人等同管理员)允许访问内网可以开', await ed.locator('[data-endpoint-intranet="prod"]').isEnabled())
await page.locator('[data-endpoint-save]').click()
check('不改认证头:原样提交掩码值(服务端认作保持原值)', await waitFor(page, async () => state.endpointWrites.length === 1)
&& state.endpointWrites[0].body?.environments?.prod?.headers?.Authorization === 'Bearer ****1234'
&& state.endpointWrites[0].body?.version === 3, JSON.stringify(state.endpointWrites[0]?.body))
})
await section('设置:身份、通知、审计', async () => {
const { page, state } = await open('/settings/identity')
const tabs = await page.locator('[role=tab]').allInnerTexts()
check('设置页有身份、通知、审计三个页签', ['身份', '通知', '审计'].every((t) => tabs.some((x) => x.includes(t))), tabs.join(' | '))
check('登录方式三选一', await page.locator('[data-identity-mode-option]').count() === 3)
await page.locator('[data-identity-mode-option="local"]').click()
check('从关闭切到本地账号:要求同时建出第一个管理员', await page.locator('[data-identity-bootstrap]').count() === 1)
check('没填管理员不能保存', await page.locator('[data-identity-save]').isDisabled())
check('单点登录同步来的组只读(没有编辑按钮)', await page.locator('[data-group="finance"] button', { hasText: '编辑' }).count() === 0)
check('还没有保存任何设置', state.settings.length === 0)
await page.goto(`${WEB}/settings/notify`, { waitUntil: 'networkidle' })
const ch = page.locator('[data-notify-channel="财务群"]')
check('通知渠道只显示主机名', await ch.count() === 1 && (await ch.innerText()).includes('open.feishu.cn'))
await page.goto(`${WEB}/settings/audit`, { waitUntil: 'networkidle' })
const row = page.locator('[data-audit-row="capability.govern"]')
check('审计照实标自报署名', await row.count() === 1 && (await row.innerText()).includes('自报署名'))
})
await section('能力:待审阅复用数据目录的审阅队列,成批确认逐项带能力的治理版本', async () => {
const { page, state, errors } = await open('/tools/capabilities?view=review')
check('左栏换成待审阅视图', await page.locator('[data-capability-views="review"]').count() === 1)
const rows = page.locator('[data-review-item]')
check('列出待审阅的能力字段', await waitFor(page, async () => await rows.count() === 2), String(await rows.count()))
check('位置照服务端的说法(返回字段 /amount 的单位)', (await rows.first().locator('[data-review-where]').innerText()).includes('返回字段 /amount 的单位'))
check('使用次数照能力说', (await rows.first().innerText()).includes('被 12 次运行用过'))
check('不分「影响数字的项 / 名称和含义」', await page.locator('[data-review-kinds]').count() === 0)
await page.locator('[data-review-select-all]').check()
await page.locator('[data-review-action="confirm"]').click()
await page.locator('[role="dialog"]').getByRole('button', { name: /确认 2 项/ }).click()
check('成批确认:一次请求、逐项带能力名、位置、字段和治理版本', await waitFor(page, async () => state.batches.length === 1)
&& JSON.stringify(state.batches[0].items.map((i) => [i.capability, i.pointer, i.field, i.action, i.if_version]))
=== JSON.stringify([['erp_pay', '/amount', 'unit', 'confirm', 4], ['erp_query', '/items/*/name', 'label', 'confirm', 2]]),
JSON.stringify(state.batches[0]))
check('冲突的那项照实标出(说的是能力,不是表)', await waitFor(page, async () =>
(await page.locator('[data-review-summary]').innerText()).includes('所在能力刚被修改过')))
await rows.first().locator('[data-review-open]').click()
check('点开一项:右栏是这个能力的治理设置,标出那个位置', await waitFor(page, async () =>
await page.locator('[data-capability-editor="erp_pay"] [data-capability-focus="/amount"]').count() === 1), page.url())
check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | '))
})
await section('自定义工具:绑定登记的端点,只填路径', async () => {
const { page, state } = await open('/tools/custom')
await page.getByRole('button', { name: '新建工具' }).first().click()
const dlg = page.locator('[role="dialog"]')
await dlg.locator('input[placeholder="query_order_status"]').fill('erp_order')
await dlg.locator('[data-custom-tool-endpoint]').selectOption('ep1')
check('绑定后只填路径,不再要完整地址', await dlg.locator('[data-custom-tool-path]').count() === 1
&& await dlg.locator('input[placeholder^="https://api.example.com"]').count() === 0)
await dlg.locator('[data-custom-tool-path]').fill('/orders/{{ order_id }}')
await dlg.getByRole('button', { name: '保存', exact: true }).click()
const body = (await waitFor(page, async () => state.tools.length === 1)) ? state.tools[0].body : null
check('保存的配置是 {endpoint, method, path, query},没有地址和请求头', body?.config?.endpoint === 'ep1'
&& body.config.path === '/orders/{{ order_id }}' && !('url' in body.config) && !('headers' in body.config), JSON.stringify(body?.config))
})
await section('MCP:认证头按掩码编辑;本地子进程只有管理员能建、能改', async () => {
const builder = { mode: 'local', username: 'li', display_name: '李工', role: 'builder', groups: [], verified: true }
const { page, state } = await open('/tools/mcp', { me: builder })
await page.locator('article', { hasText: 'files' }).getByRole('button', { name: '编辑' }).click()
let dlg = page.locator('[role="dialog"]')
check('搭建者打开本地子进程服务:只读,说明原因,没有保存', await dlg.locator('[data-mcp-stdio-locked]').count() === 1
&& await dlg.getByRole('button', { name: '保存', exact: true }).count() === 0)
await dlg.getByRole('button', { name: '取消' }).click()
await page.locator('article', { hasText: 'crm' }).getByRole('button', { name: '编辑' }).click()
dlg = page.locator('[role="dialog"]')
check('认证头显示掩码', await dlg.locator('[data-mcp-header-value]').first().inputValue() === 'Bearer ****9a2c')
check('传输方式里本地子进程不可选', await dlg.locator('option[value="stdio"]').isDisabled())
await dlg.getByRole('button', { name: '保存', exact: true }).click()
check('不改认证头:原样提交掩码(服务端认作保持原值)', await waitFor(page, async () => state.mcp.length === 1)
&& state.mcp[0].body?.headers?.Authorization === 'Bearer ****9a2c', JSON.stringify(state.mcp[0]?.body))
})
await section('身份:单点登录映射管理员,用户或组至少一项', async () => {
const { page } = await open('/settings/identity')
await page.locator('[data-identity-mode-option="oidc"]').click()
check('有「映射为管理员的用户」', await page.locator('[data-identity-admin-users]').count() === 1)
check('用户和组都没填:说明至少一项,不能保存', (await page.locator('[data-identity-oidc]').innerText()).includes('至少要配置一个映射为管理员的用户或组')
&& await page.locator('[data-identity-save]').isDisabled())
await page.locator('[data-identity-admin-users]').fill('admin')
check('只填了管理员用户:可以保存', await page.locator('[data-identity-save]').isEnabled())
})
await section('设置:开了登录的使用者只剩个人的主题和通知,换主题不写服务端', async () => {
const user = { mode: 'local', username: 'wang', display_name: '王五', role: 'user', groups: [], verified: true }
const { page, state, errors } = await open('/settings/identity', { me: user })
const tabs = (await page.locator('[role=tab]').allInnerTexts()).map((t) => t.trim())
check('页签只剩偏好设置和通知', tabs.length === 2 && tabs.some((t) => t.includes('偏好')) && tabs.some((t) => t.includes('通知')), tabs.join(' | '))
check('只给管理员的页签地址纠正到偏好设置', new URL(page.url()).pathname === '/settings/prefs', page.url())
check('偏好只有主题,没有署名和运行默认值', await page.locator('[data-prefs-personal]').count() === 1
&& await page.locator('#pref-actor').count() === 0 && await page.locator('[data-prefs-shared]').count() === 0)
await page.getByRole('radio', { name: /浅色/ }).click()
await page.waitForTimeout(300)
check('换主题只存本机,不往服务端写', state.prefs.length === 0, JSON.stringify(state.prefs))
check('说明主题只保存在当前浏览器', (await page.locator('body').innerText()).includes('主题只保存在当前浏览器中'))
check('没有报错提示', !(await page.locator('body').innerText()).includes('未能保存到设置'))
check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | '))
})
check('检查没有写库(兜底拦下的写请求)', writes.length === 0, writes.slice(0, 4).join('、'))
console.log(failed ? `\n${failed} 项未通过` : '\n全部通过')
await browser.close()
process.exit(failed ? 1 : 0)