This commit is contained in:
gr
2026-09-24 16:18:52 +08:00
parent b7e91f5fe3
commit 8e2b4fc763
7 changed files with 421 additions and 2 deletions
@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace app\mcp\controller;
use app\BaseController;
use app\mcp\service\AuditLogger;
use app\mcp\service\ConsoleService;
use app\mcp\service\GrantService;
use app\mcp\service\Guard;
use app\mcp\service\McpConfig;
use app\mcp\service\McpException;
use think\Response;
/**
* AI 后台浏览器(供行知服务器上的内置浏览器调用,都要求 Bearer AI 授权令牌):
* POST /mcp/console/open 换取“AI 浏览器”专用终端的后台登录(令牌只给行知服务器,写进浏览器,不给模型)
* POST /mcp/console/close 作废该会话(行知关闭浏览器或空闲超时时调用;授权已失效时也照样注销)
* 失败时 data.reason 为 feature_disabled / console_disabled / no_console_permission / ip_not_allowed 等。
*/
class ConsoleController extends BaseController
{
public function open(): Response
{
$blocked = $this->blocked();
if ($blocked) {
return $blocked;
}
try {
$identity = GrantService::authenticate($this->request);
} catch (McpException $e) {
return Guard::envelope(-1, $e->getMessage(), ['reason' => $e->reason], 401);
}
$entry = ['grant_id' => $identity->grant['id'], 'admin_id' => $identity->adminId, 'tool' => 'console.open', 'resource' => 'console',
'client_task_id' => (string) $this->request->header('x-xingzhi-task-id', ''), 'ip' => $this->request->ip()];
try {
$data = ConsoleService::open($identity);
AuditLogger::log($entry + ['status' => 'ok']);
return Guard::envelope(1, '', $data);
} catch (McpException $e) {
AuditLogger::log($entry + ['status' => 'denied', 'message' => $e->reason]);
return Guard::envelope(0, $e->getMessage(), ['reason' => $e->reason], 200, 1);
}
}
public function close(): Response
{
$blocked = $this->blocked();
if ($blocked) {
return $blocked;
}
try {
$identity = GrantService::authenticate($this->request);
[$grantId, $adminId, $note] = [(int) $identity->grant['id'], $identity->adminId, ''];
} catch (McpException $e) {
// 授权已撤销、过期或账号失去权限时也照样注销它换来的后台会话:收回登录不需要授权仍然有效
$grant = GrantService::findByToken($this->request);
if (!$grant) {
return Guard::envelope(-1, $e->getMessage(), ['reason' => $e->reason], 401);
}
[$grantId, $adminId, $note] = [(int) $grant['id'], (int) $grant['admin_id'], ' (grant ' . $e->reason . ')'];
}
$closed = ConsoleService::closeForAdmin($adminId);
AuditLogger::log(['grant_id' => $grantId, 'admin_id' => $adminId, 'tool' => 'console.close', 'resource' => 'console',
'status' => 'ok', 'message' => ($closed ? 'closed' : 'none') . $note, 'ip' => $this->request->ip()]);
return Guard::envelope(1, $closed ? '已关闭' : '没有需要关闭的会话', ['closed' => $closed]);
}
private function blocked(): ?Response
{
if (!McpConfig::enabled()) {
return Guard::envelope(0, 'AI 助手接口未启用', ['reason' => 'feature_disabled'], 503, 1);
}
if ($this->request->method(true) !== 'POST') {
return response('', 405)->header(['Allow' => 'POST']);
}
$guard = Guard::check($this->request);
if ($guard !== null) {
return Guard::envelope(0, $guard[1], ['reason' => $guard[2]], 200, 1);
}
return null;
}
}
+94
View File
@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace app\mcp\service;
use app\adminapi\service\AdminTokenService;
use app\common\cache\AdminTokenCache;
use app\common\model\auth\AdminSession;
use think\cache\driver\File as FileCache;
/**
* AI 后台浏览器会话:用已绑定的 AI 授权换一个“AI 浏览器”专用终端的后台登录,供行知服务器上的内置浏览器
* 打开本后台页面(不用输入密码)。
* - 独立终端(terminal=8):与电脑(1)、手机(2)、企微客服(7) 的登录互不影响,不会把员工自己的后台挤下线;
* admin_session 按“账号 + 终端”唯一,同一账号的 AI 浏览器共用一个会话。
* - 前提:AI 授权有效(未撤销、未过期、账号可用、仍有 ai.mcp/access)且账号有 ai.mcp/console(默认不授予任何角色)。
* - 创建时不写登录缓存:后台按“登录 IP”校验请求,缓存由浏览器的第一次请求建立,记录的就是浏览器实际的出口 IP。
* - 行知关闭浏览器、空闲超时、AI 授权被撤销时立即作废(改到期时间并清缓存)。
* 这是完整的后台登录(按账号自身的菜单权限和数据范围),不经过 MCP 的只读保护和脱敏;
* 行知对其中每一个会修改数据的请求都先请用户审批,后台自己的操作日志也能按终端区分出 AI 浏览器。
*/
class ConsoleService
{
/** 后台登录终端:AI 浏览器(系统已用 1 电脑、2 手机、7 企微客服桌面端) */
public const TERMINAL = 8;
public const PERMISSION = 'ai.mcp/console';
/** 后台前端把登录令牌存在 localStorage 的这个键里(admin/src/utils/cache.ts:前缀 like_admin_ + token) */
private const STORAGE_KEY = 'like_admin_token';
public static function open(Identity $identity): array
{
if (!McpConfig::consoleEnabled()) {
throw new McpException('后台浏览器未启用', 'console_disabled', 403);
}
if (!$identity->can(self::PERMISSION)) {
throw new McpException('当前账号没有“允许 AI 使用后台浏览器”权限(ai.mcp/console),请联系管理员在角色里勾选', 'no_console_permission', 403);
}
// 第三个参数 1:同一终端已有未过期的会话就沿用,不轮换令牌(不影响其他终端)
AdminTokenService::setToken($identity->adminId, self::TERMINAL, 1);
$session = AdminSession::where(['admin_id' => $identity->adminId, 'terminal' => self::TERMINAL])->findOrEmpty();
if ($session->isEmpty()) {
throw new McpException('后台会话创建失败,请稍后再试', 'console_failed', 500);
}
$now = time();
$session->expire_time = $now + McpConfig::consoleTtlMinutes() * 60;
$session->update_time = $now;
$session->save();
// setToken 按本次请求的 IP 写了登录缓存;删掉,让浏览器第一次请求时按它自己的 IP 重建
self::forgetLogin((string) $session->token);
return [
'token' => (string) $session->token,
'expire_time' => (int) $session->expire_time,
'terminal' => self::TERMINAL,
'local_storage' => [self::STORAGE_KEY => json_encode(['expire' => '', 'value' => (string) $session->token])],
'start_path' => '/admin/',
];
}
/** 作废该账号的 AI 浏览器会话;没有有效会话时返回 false */
public static function closeForAdmin(int $adminId): bool
{
$session = AdminSession::where(['admin_id' => $adminId, 'terminal' => self::TERMINAL])->findOrEmpty();
if ($session->isEmpty() || (int) $session->expire_time <= time()) {
return false;
}
// 到期时间记为上一秒:setToken 只在“已过期”(expire_time < 当前秒) 时换新令牌,同一秒内重新打开也不会复用旧令牌
$session->expire_time = time() - 1;
$session->update_time = time();
$session->save();
self::forgetLogin((string) $session->token);
return true;
}
/**
* 清掉后台对这个令牌的登录缓存(后台先查缓存、查不到才回表看到期时间,所以作废会话必须清缓存)。
* 文件缓存按应用分目录(config/cache.php 的 path 为空):后台请求用 runtime/adminapi/cache,
* 本模块在 runtime/mcp/cache,所以文件缓存时要按后台的目录再删一次;redis 等共享缓存删一次即可。
*/
private static function forgetLogin(string $token): void
{
(new AdminTokenCache())->deleteAdminInfo($token);
if ((string) config('cache.default') !== 'file') {
return;
}
$options = (array) config('cache.stores.file');
if (($options['path'] ?? '') !== '') {
return; // 配了固定目录:所有应用共用,上面已经删过
}
$options['path'] = app()->getRootPath() . 'runtime' . DIRECTORY_SEPARATOR . 'adminapi' . DIRECTORY_SEPARATOR . 'cache';
(new FileCache(app(), $options))->delete('token_admin_' . $token);
}
}
+25 -1
View File
@@ -136,6 +136,9 @@ class GrantService
} catch (McpException $e) {
if ($e->reason === 'disabled') {
self::close((int) $grant['id'], self::STATUS_REVOKED, 'admin_disabled');
} else {
// 授权保留(补上权限或绑定企微后可继续用),但它换来的 AI 浏览器后台会话立即作废
self::endConsole((int) $grant['admin_id']);
}
throw new McpException($e->getMessage(), $e->reason, 401);
}
@@ -154,13 +157,34 @@ class GrantService
public static function close(int $grantId, int $status, string $reason, int $by = 0): void
{
$now = time();
Db::name('ai_grant')->where(['id' => $grantId, 'status' => self::STATUS_ACTIVE])->update([
$closed = Db::name('ai_grant')->where(['id' => $grantId, 'status' => self::STATUS_ACTIVE])->update([
'status' => $status,
'revoke_time' => $now,
'revoke_by' => $by,
'revoke_reason' => substr($reason, 0, 64),
'update_time' => $now,
]);
// 授权失效时,用它换来的 AI 浏览器后台会话一并作废
if ($closed) {
self::endConsole((int) Db::name('ai_grant')->where('id', $grantId)->value('admin_id'));
}
}
/** 按令牌找授权记录,不论是否仍有效;只用于注销 AI 浏览器会话这类“收回”操作 */
public static function findByToken(Request $request): array
{
$token = TokenService::fromRequest($request);
return $token === '' ? [] : (Db::name('ai_grant')->where('token_hash', TokenService::hash($token))->find() ?: []);
}
/** 作废该账号的 AI 浏览器后台会话;失败只记日志,不影响调用方 */
private static function endConsole(int $adminId): void
{
try {
ConsoleService::closeForAdmin($adminId);
} catch (\Throwable $e) {
\think\facade\Log::error('[ai_mcp] 作废 AI 浏览器会话失败: ' . $e->getMessage());
}
}
public static function publicGrant(array $grant): array
+12
View File
@@ -115,6 +115,18 @@ class McpConfig
return self::int('max_file_bytes', 5242880, 1024, 20971520);
}
/** AI 后台浏览器总开关(还需要账号有 ai.mcp/console 权限点);紧急停用时设为 false */
public static function consoleEnabled(): bool
{
return self::bool('console_enabled', true);
}
/** AI 浏览器后台会话的有效期(分钟);行知空闲或关闭浏览器时会提前注销 */
public static function consoleTtlMinutes(): int
{
return self::int('console_ttl_minutes', 120, 10, 480);
}
private static function raw(string $key)
{
return env('ai_mcp.' . $key);
@@ -0,0 +1,18 @@
-- AI 后台浏览器:新增权限点 ai.mcp/console(在 2026_09_24_ai_mcp.sql 之后执行)。
-- 只新增菜单按钮,不修改任何已有表结构或已有菜单。可重复执行。表前缀如非 zyt_ 请整体替换。
--
-- ai.mcp/console 允许 AI 使用后台浏览器:行知可用该账号已绑定的 AI 授权,换一个“AI 浏览器”专用终端的后台登录,
-- 在行知服务器上的内置浏览器里打开本后台(不用输入密码,也不会挤掉该账号在电脑、手机、企微客服端的登录)。
-- 这是完整的后台登录(按账号自己的菜单权限和数据范围);行知对其中每一个会修改数据的请求都先请用户审批。
-- 默认不授予任何角色,请在「权限管理 > 角色」中按需勾选;root 自动拥有。
SET @ai_grant_id = (SELECT `id` FROM `zyt_system_menu` WHERE `perms` = 'ai.grant/lists' ORDER BY `id` ASC LIMIT 1);
INSERT INTO `zyt_system_menu`
(`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, `selected`, `params`, `is_cache`, `is_show`, `is_disable`, `create_time`, `update_time`)
SELECT @ai_grant_id, 'A', '允许 AI 使用后台浏览器', '', 3, 'ai.mcp/console', '', '', '', '', 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
FROM DUAL
WHERE @ai_grant_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM `zyt_system_menu` WHERE `perms` = 'ai.mcp/console');
COMMIT;
+164
View File
@@ -0,0 +1,164 @@
<?php
declare(strict_types=1);
/**
* AI 后台浏览器会话(/mcp/console/open|close)测试:权限点门禁、专用终端(terminal=8)不影响电脑端登录、
* 换来的令牌能直接访问后台(按浏览器自己的 IP 建立登录缓存)、复用、关闭即失效、撤销 AI 授权时一并作废、
* 授权失效或账号失去 ai.mcp/access 后也能收回、审计。
*
* 需要一次性测试库(库名以 _test 结尾,已执行 2026_09_24_ai_mcp.sql 和 2026_09_24_ai_mcp_console.sql)和指向它的运行实例:
* AI_MCP_TEST_MYSQL=1 AI_MCP_TEST_BASE_URL=http://127.0.0.1:8099 php server/tests/AiMcpConsoleTest.php
* 夹具 ID 段:账号 93001-93002、角色 293-294。
*/
require dirname(__DIR__) . '/vendor/autoload.php';
use think\App;
use think\facade\Db;
function aiMcpConsoleExpect(bool $condition, string $message): void
{
if (!$condition) {
fwrite(STDERR, "FAIL: {$message}\n");
exit(1);
}
}
$base = rtrim((string) getenv('AI_MCP_TEST_BASE_URL'), '/');
if (getenv('AI_MCP_TEST_MYSQL') !== '1' || $base === '') {
echo "AiMcpConsoleTest SKIP (set AI_MCP_TEST_MYSQL=1, AI_MCP_TEST_BASE_URL and PHP_DATABASE_* for a disposable *_test database)\n";
exit(0);
}
$app = new App(dirname(__DIR__) . DIRECTORY_SEPARATOR);
$app->initialize();
$database = (string) config('database.connections.' . config('database.default') . '.database');
aiMcpConsoleExpect(str_ends_with($database, '_test'), "refusing to run on database '{$database}' (name must end with _test)");
aiMcpConsoleExpect((int) Db::name('system_menu')->where('perms', 'ai.mcp/console')->count() === 1, 'run 2026_09_24_ai_mcp_console.sql on the test database first');
// ---------------------------------------------------------------- 夹具
$now = time();
$pwd = create_password('Test@123456', (string) config('project.unique_identification'));
Db::name('system_role')->whereIn('id', [293, 294])->delete();
foreach ([293 => '仅 AI 查询', 294 => 'AI 查询 + 后台浏览器'] as $id => $name) {
Db::name('system_role')->insert(['id' => $id, 'name' => $name, 'desc' => 'ai-mcp-console-test', 'sort' => 0, 'data_scope' => 4, 'create_time' => $now, 'update_time' => $now]);
}
Db::name('system_role_menu')->whereIn('role_id', [293, 294])->delete();
$menu = static fn (string $perm): int => (int) Db::name('system_menu')->where('perms', $perm)->value('id');
foreach ([293 => ['ai.mcp/access'], 294 => ['ai.mcp/access', 'ai.mcp/console']] as $role => $perms) {
foreach ($perms as $perm) {
Db::name('system_role_menu')->insert(['role_id' => $role, 'menu_id' => $menu($perm)]);
}
}
$admins = [93001 => ['c_plain', 293], 93002 => ['c_console', 294]];
Db::name('admin')->whereIn('id', array_keys($admins))->delete();
Db::name('admin_role')->whereIn('admin_id', array_keys($admins))->delete();
Db::name('admin_session')->whereIn('admin_id', array_keys($admins))->delete();
Db::name('ai_grant')->whereIn('admin_id', array_keys($admins))->delete();
Db::name('ai_access_log')->whereIn('admin_id', array_keys($admins))->delete();
foreach ($admins as $id => [$account, $role]) {
// 关闭“多处登录”:若 AI 浏览器用的是电脑端终端,就会把下面这条电脑端会话挤掉
Db::name('admin')->insert(['id' => $id, 'root' => 0, 'name' => $account, 'avatar' => '', 'account' => $account, 'password' => $pwd, 'multipoint_login' => 0,
'is_paw' => 1, 'work_wechat_userid' => '', 'disable' => 0, 'phone' => '1360000' . substr((string) $id, -4), 'create_time' => $now, 'update_time' => $now]);
Db::name('admin_role')->insert(['admin_id' => $id, 'role_id' => $role]);
}
$pcToken = substr(md5('console-test-pc-' . $now), 0, 32);
Db::name('admin_session')->insert(['admin_id' => 93002, 'terminal' => 1, 'token' => $pcToken, 'update_time' => $now, 'expire_time' => $now + 3600]);
\think\facade\Cache::clear();
// ---------------------------------------------------------------- HTTP 工具
function aiMcpConsoleHttp(string $method, string $url, ?array $body, array $headers): array
{
$ch = curl_init($url);
$lines = ['Content-Type: application/json'];
foreach ($headers as $k => $v) {
$lines[] = $k . ': ' . $v;
}
curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => $lines, CURLOPT_TIMEOUT => 60]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body, JSON_UNESCAPED_UNICODE));
}
$raw = (string) curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
return [$status, json_decode($raw, true)];
}
$grant = static function (string $account) use ($base): string {
[, $body] = aiMcpConsoleHttp('POST', $base . '/mcp/auth/grant', ['account' => $account, 'password' => 'Test@123456', 'client' => 'xingzhi', 'client_instance' => 'console-test'], []);
aiMcpConsoleExpect(($body['code'] ?? null) === 1, "grant for {$account}: " . json_encode($body, JSON_UNESCAPED_UNICODE));
return (string) $body['data']['token'];
};
$console = static function (string $action, string $grantToken) use ($base): array {
[, $body] = aiMcpConsoleHttp('POST', $base . '/mcp/console/' . $action, [], ['Authorization' => 'Bearer ' . $grantToken, 'X-Xingzhi-Task-Id' => 'console-task']);
return (array) $body;
};
$backOffice = static function (string $sessionToken) use ($base): array {
[, $body] = aiMcpConsoleHttp('GET', $base . '/adminapi/auth.admin/mySelf', null, ['token' => $sessionToken]);
return (array) $body;
};
$session = static fn (int $adminId, int $terminal) => Db::name('admin_session')->where(['admin_id' => $adminId, 'terminal' => $terminal])->find();
// ---------------------------------------------------------------- 门禁
[$status] = aiMcpConsoleHttp('GET', $base . '/mcp/console/open', null, []);
aiMcpConsoleExpect($status === 405, 'console endpoints are POST only');
$body = $console('open', 'zyt_ai_' . str_repeat('0', 64));
aiMcpConsoleExpect(($body['code'] ?? null) === -1, 'an invalid AI grant cannot open a console session');
$plain = $grant('c_plain');
$body = $console('open', $plain);
aiMcpConsoleExpect(($body['code'] ?? null) === 0 && ($body['data']['reason'] ?? '') === 'no_console_permission', 'accounts without ai.mcp/console are refused: ' . json_encode($body, JSON_UNESCAPED_UNICODE));
aiMcpConsoleExpect($session(93001, 8) === null, 'no session is created when refused');
// ---------------------------------------------------------------- 签发与使用
$grantToken = $grant('c_console');
$body = $console('open', $grantToken);
aiMcpConsoleExpect(($body['code'] ?? null) === 1 && strlen((string) ($body['data']['token'] ?? '')) === 32 && ($body['data']['terminal'] ?? 0) === 8, 'console session issued: ' . json_encode($body, JSON_UNESCAPED_UNICODE));
$token = $body['data']['token'];
$stored = json_decode((string) ($body['data']['local_storage']['like_admin_token'] ?? ''), true);
aiMcpConsoleExpect(($stored['value'] ?? '') === $token, 'local storage entry is what the admin front end reads');
$row = $session(93002, 8);
aiMcpConsoleExpect($row && $row['token'] === $token && abs((int) $row['expire_time'] - ($now + 7200)) < 120, 'session row on the AI browser terminal with a two hour lifetime');
aiMcpConsoleExpect(($session(93002, 1)['token'] ?? '') === $pcToken, 'the account\'s own PC login is untouched even with multipoint login off');
$me = $backOffice($token);
aiMcpConsoleExpect(($me['code'] ?? null) === 1 && (int) ($me['data']['user']['id'] ?? 0) === 93002, 'the console token works against the back office (login cache built for the caller\'s IP): ' . json_encode($me, JSON_UNESCAPED_UNICODE));
aiMcpConsoleExpect(($console('open', $grantToken)['data']['token'] ?? '') === $token, 'opening again reuses the live session');
// ---------------------------------------------------------------- 关闭与撤销
$body = $console('close', $grantToken);
aiMcpConsoleExpect(($body['code'] ?? null) === 1 && ($body['data']['closed'] ?? null) === true, 'close ends the session');
aiMcpConsoleExpect(($backOffice($token)['code'] ?? null) === -1, 'a closed console token is rejected by the back office');
aiMcpConsoleExpect(($console('close', $grantToken)['data']['closed'] ?? null) === false, 'closing twice is harmless');
$second = $console('open', $grantToken)['data']['token'] ?? '';
aiMcpConsoleExpect($second !== '' && $second !== $token && ($backOffice($second)['code'] ?? null) === 1, 'a new session gets a new token');
[, $body] = aiMcpConsoleHttp('POST', $base . '/mcp/auth/revoke', [], ['Authorization' => 'Bearer ' . $grantToken]);
aiMcpConsoleExpect(($body['code'] ?? null) === 1, 'AI grant revoked');
aiMcpConsoleExpect(($backOffice($second)['code'] ?? null) === -1, 'revoking the AI grant also ends its console session');
aiMcpConsoleExpect(($session(93002, 1)['token'] ?? '') === $pcToken && (int) $session(93002, 1)['expire_time'] > time(), 'the PC login still stands after all of it');
// ---------------------------------------------------------------- 授权失效、失去权限后也能收回
aiMcpConsoleExpect(($console('close', 'zyt_ai_' . str_repeat('0', 64))['code'] ?? null) === -1, 'an unknown token cannot close anything');
$third = $grant('c_console');
$live = $console('open', $third)['data']['token'] ?? '';
aiMcpConsoleExpect($live !== '' && ($backOffice($live)['code'] ?? null) === 1, 'a new binding opens a new session');
Db::name('ai_grant')->where(['admin_id' => 93002, 'status' => 1])->update(['status' => 2, 'revoke_reason' => 'test']); // 失效但没经过 close()
$body = $console('close', $third);
aiMcpConsoleExpect(($body['code'] ?? null) === 1 && ($body['data']['closed'] ?? null) === true, 'close still logs out with a grant that is no longer valid: ' . json_encode($body, JSON_UNESCAPED_UNICODE));
aiMcpConsoleExpect(($backOffice($live)['code'] ?? null) === -1, 'and the session is gone');
$fourth = $grant('c_console');
$live = $console('open', $fourth)['data']['token'] ?? '';
aiMcpConsoleExpect($live !== '' && ($backOffice($live)['code'] ?? null) === 1, 'reopened with a fresh binding');
Db::name('system_role_menu')->where(['role_id' => 294, 'menu_id' => $menu('ai.mcp/access')])->delete();
$body = $console('open', $fourth);
aiMcpConsoleExpect(($body['code'] ?? null) === -1 && ($body['data']['reason'] ?? '') === 'no_ai_permission', 'losing ai.mcp/access refuses the grant: ' . json_encode($body, JSON_UNESCAPED_UNICODE));
aiMcpConsoleExpect(($backOffice($live)['code'] ?? null) === -1, 'losing ai.mcp/access also ends the live console session');
aiMcpConsoleExpect((int) Db::name('ai_grant')->where('admin_id', 93002)->order('id', 'desc')->value('status') === 1, 'the grant itself stays (usable again once the permission is back)');
Db::name('system_role_menu')->insert(['role_id' => 294, 'menu_id' => $menu('ai.mcp/access')]);
// ---------------------------------------------------------------- 审计
$logged = Db::name('ai_access_log')->where('admin_id', 93002)->whereIn('tool', ['console.open', 'console.close'])->column('tool');
aiMcpConsoleExpect(count(array_filter($logged, static fn ($t) => $t === 'console.open')) >= 3 && in_array('console.close', $logged, true), 'console sessions are audited');
aiMcpConsoleExpect(Db::name('ai_access_log')->where(['admin_id' => 93002, 'tool' => 'console.open'])->where('client_task_id', 'console-task')->count() >= 1, 'the 行知 task id is recorded');
aiMcpConsoleExpect(Db::name('ai_access_log')->where(['admin_id' => 93001, 'tool' => 'console.open', 'status' => 'denied'])->count() === 1, 'refusals are audited');
echo "AiMcpConsoleTest OK\n";