diff --git a/.runtime/portal/RUNBOOK.txt b/.runtime/portal/RUNBOOK.txt index 345d813..75f8430 100644 --- a/.runtime/portal/RUNBOOK.txt +++ b/.runtime/portal/RUNBOOK.txt @@ -39,3 +39,4 @@ Streaming runtime operations: node scripts/runtime-worker-drain.mjs drain goosed-3 node scripts/runtime-worker-drain.mjs undrain goosed-3 node scripts/check-tool-runtime.mjs + node scripts/check-agent-code-run-entry.mjs diff --git a/.runtime/portal/scripts/check-agent-code-run-entry.mjs b/.runtime/portal/scripts/check-agent-code-run-entry.mjs new file mode 100644 index 0000000..348b6ca --- /dev/null +++ b/.runtime/portal/scripts/check-agent-code-run-entry.mjs @@ -0,0 +1,182 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import path from 'node:path'; +import { Agent, fetch as undiciFetch } from 'undici'; +import mysql from 'mysql2/promise'; + +function loadEnvFile(filePath) { + if (!fs.existsSync(filePath)) return; + for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const idx = trimmed.indexOf('='); + if (idx < 0) continue; + const key = trimmed.slice(0, idx).trim(); + const value = trimmed.slice(idx + 1).trim(); + if (!process.env[key]) process.env[key] = value; + } +} + +function parseMysqlConfig() { + if (process.env.DATABASE_URL) { + const url = new URL(process.env.DATABASE_URL); + if (url.protocol !== 'mysql:') { + throw new Error(`Unsupported DATABASE_URL scheme for code-run entry check: ${url.protocol}`); + } + return { + host: url.hostname, + port: Number(url.port || 3306), + user: decodeURIComponent(url.username), + password: decodeURIComponent(url.password), + database: url.pathname.replace(/^\/+/, ''), + charset: 'utf8mb4', + }; + } + return { + host: process.env.MYSQL_HOST, + port: Number(process.env.MYSQL_PORT || 3306), + user: process.env.MYSQL_USER, + password: process.env.MYSQL_PASSWORD, + database: process.env.MYSQL_DATABASE, + charset: 'utf8mb4', + }; +} + +function truthy(value) { + return ['1', 'true', 'yes', 'on'].includes(String(value ?? '').trim().toLowerCase()); +} + +function listJsAssets(distRoot) { + const assetsDir = path.join(distRoot, 'assets'); + if (!fs.existsSync(assetsDir)) return []; + return fs.readdirSync(assetsDir) + .filter((name) => name.endsWith('.js')) + .map((name) => path.join(assetsDir, name)); +} + +function checkFrontendBundle(distRoot) { + const jsAssets = listJsAssets(distRoot); + const text = jsAssets.map((file) => fs.readFileSync(file, 'utf8')).join('\n'); + return { + distRoot, + jsAssetCount: jsAssets.length, + hasAgentRunEndpoint: text.includes('/agent/runs'), + hasToolModePayload: text.includes('tool_mode'), + hasCodeLiteral: text.includes('"code"') || text.includes("'code'") || text.includes(':code'), + note: 'VITE_AGENT_CODE_RUNS_* are build-time flags; runtime checks can prove code entry is bundled, not whether a past build enabled the flag.', + }; +} + +async function queryToolGrants() { + const conn = await mysql.createConnection(parseMysqlConfig()); + try { + const [roleRows] = await conn.query( + `SELECT capability_key, allowed + FROM h5_capability_grants + WHERE subject_type = 'role' + AND subject_id = 'user' + AND capability_key IN ('aider', 'openhands') + ORDER BY capability_key`, + ); + const roleDefaults = Object.fromEntries( + roleRows.map((row) => [row.capability_key, Boolean(row.allowed)]), + ); + + const [userRows] = await conn.query( + `SELECT subject_id, capability_key, allowed + FROM h5_capability_grants + WHERE subject_type = 'user' + AND capability_key IN ('aider', 'openhands') + AND allowed = 1 + ORDER BY subject_id, capability_key`, + ); + + const grantsByUser = new Map(); + for (const row of userRows) { + if (!grantsByUser.has(row.subject_id)) grantsByUser.set(row.subject_id, []); + grantsByUser.get(row.subject_id).push(row.capability_key); + } + return { + roleDefaults, + whitelistUserCount: grantsByUser.size, + sampledUsers: [...grantsByUser.entries()].slice(0, 10).map(([userId, grantedTools]) => ({ + userId, + grantedTools: grantedTools.sort(), + chatHasCodeTools: false, + codeHasCodeTools: grantedTools.includes('aider') || grantedTools.includes('openhands'), + })), + }; + } finally { + await conn.end(); + } +} + +const envFile = process.env.MEMIND_ENV_FILE || path.join(process.cwd(), '.env'); +loadEnvFile(envFile); + +const distRoot = path.resolve(process.env.MEMIND_H5_DIST_ROOT || path.join(process.cwd(), 'dist')); +const publicBase = (process.env.H5_PUBLIC_BASE_URL || 'https://mm.tkmind.cn').replace(/\/$/, ''); +const insecureDispatcher = new Agent({ connect: { rejectUnauthorized: false } }); + +async function fetchJson(pathname) { + const res = await undiciFetch(`${publicBase}${pathname}`, { + dispatcher: publicBase.startsWith('https://127.0.0.1') ? insecureDispatcher : undefined, + }); + const text = await res.text(); + let json = null; + try { + json = JSON.parse(text); + } catch { + json = null; + } + return { ok: res.ok, status: res.status, json, text: json ? undefined : text.slice(0, 160) }; +} + +const frontend = checkFrontendBundle(distRoot); +const runtime = await fetchJson('/api/runtime/status').catch((err) => ({ ok: false, error: err.message })); +const toolGrants = await queryToolGrants().catch((err) => ({ + ok: false, + error: err instanceof Error ? (err.message || err.code || err.name) : String(err), +})); + +const buildFlags = { + enabledEnv: process.env.VITE_AGENT_CODE_RUNS_ENABLED ?? null, + autodetectEnv: process.env.VITE_AGENT_CODE_RUNS_AUTODETECT ?? null, + enabledTruthy: truthy(process.env.VITE_AGENT_CODE_RUNS_ENABLED), + autodetectTruthy: truthy(process.env.VITE_AGENT_CODE_RUNS_AUTODETECT), + note: 'These env values describe the current shell, not necessarily the already-built dist.', +}; + +const runtimePolicy = runtime.json?.toolRuntime ?? {}; +const roleDefaults = toolGrants.roleDefaults ?? {}; +const ok = Boolean( + frontend.jsAssetCount > 0 && + frontend.hasAgentRunEndpoint && + frontend.hasToolModePayload && + runtime.ok && + runtimePolicy.defaultMode === 'chat' && + runtimePolicy.codeToolMode === 'code' && + runtimePolicy.chatInjectsCodeTools === false && + roleDefaults.aider === false && + roleDefaults.openhands === false && + (toolGrants.sampledUsers ?? []).every((user) => user.chatHasCodeTools === false) && + (toolGrants.sampledUsers ?? []).every((user) => user.codeHasCodeTools === true) +); + +console.log(JSON.stringify({ + ok, + checkedAt: new Date().toISOString(), + publicBase, + envFile, + frontend, + buildFlags, + runtimePolicy, + toolGrants, + writes: { + database: false, + mindSpace: false, + createsAgentRun: false, + }, +}, null, 2)); + +process.exit(ok ? 0 : 1); diff --git a/scripts/build-portal-runtime.mjs b/scripts/build-portal-runtime.mjs index 63d9f19..26d7cc9 100755 --- a/scripts/build-portal-runtime.mjs +++ b/scripts/build-portal-runtime.mjs @@ -279,6 +279,10 @@ async function writeMetadata() { path.join(root, 'scripts', 'check-tool-runtime.mjs'), path.join(runtimeRoot, 'scripts', 'check-tool-runtime.mjs'), ); + await fs.copyFile( + path.join(root, 'scripts', 'check-agent-code-run-entry.mjs'), + path.join(runtimeRoot, 'scripts', 'check-agent-code-run-entry.mjs'), + ); await writeFile( path.join(runtimeRoot, 'RUNBOOK.txt'), [ @@ -323,6 +327,7 @@ async function writeMetadata() { ' node scripts/runtime-worker-drain.mjs drain goosed-3', ' node scripts/runtime-worker-drain.mjs undrain goosed-3', ' node scripts/check-tool-runtime.mjs', + ' node scripts/check-agent-code-run-entry.mjs', '', ].join('\n'), ); diff --git a/scripts/check-agent-code-run-entry.mjs b/scripts/check-agent-code-run-entry.mjs new file mode 100644 index 0000000..348b6ca --- /dev/null +++ b/scripts/check-agent-code-run-entry.mjs @@ -0,0 +1,182 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import path from 'node:path'; +import { Agent, fetch as undiciFetch } from 'undici'; +import mysql from 'mysql2/promise'; + +function loadEnvFile(filePath) { + if (!fs.existsSync(filePath)) return; + for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const idx = trimmed.indexOf('='); + if (idx < 0) continue; + const key = trimmed.slice(0, idx).trim(); + const value = trimmed.slice(idx + 1).trim(); + if (!process.env[key]) process.env[key] = value; + } +} + +function parseMysqlConfig() { + if (process.env.DATABASE_URL) { + const url = new URL(process.env.DATABASE_URL); + if (url.protocol !== 'mysql:') { + throw new Error(`Unsupported DATABASE_URL scheme for code-run entry check: ${url.protocol}`); + } + return { + host: url.hostname, + port: Number(url.port || 3306), + user: decodeURIComponent(url.username), + password: decodeURIComponent(url.password), + database: url.pathname.replace(/^\/+/, ''), + charset: 'utf8mb4', + }; + } + return { + host: process.env.MYSQL_HOST, + port: Number(process.env.MYSQL_PORT || 3306), + user: process.env.MYSQL_USER, + password: process.env.MYSQL_PASSWORD, + database: process.env.MYSQL_DATABASE, + charset: 'utf8mb4', + }; +} + +function truthy(value) { + return ['1', 'true', 'yes', 'on'].includes(String(value ?? '').trim().toLowerCase()); +} + +function listJsAssets(distRoot) { + const assetsDir = path.join(distRoot, 'assets'); + if (!fs.existsSync(assetsDir)) return []; + return fs.readdirSync(assetsDir) + .filter((name) => name.endsWith('.js')) + .map((name) => path.join(assetsDir, name)); +} + +function checkFrontendBundle(distRoot) { + const jsAssets = listJsAssets(distRoot); + const text = jsAssets.map((file) => fs.readFileSync(file, 'utf8')).join('\n'); + return { + distRoot, + jsAssetCount: jsAssets.length, + hasAgentRunEndpoint: text.includes('/agent/runs'), + hasToolModePayload: text.includes('tool_mode'), + hasCodeLiteral: text.includes('"code"') || text.includes("'code'") || text.includes(':code'), + note: 'VITE_AGENT_CODE_RUNS_* are build-time flags; runtime checks can prove code entry is bundled, not whether a past build enabled the flag.', + }; +} + +async function queryToolGrants() { + const conn = await mysql.createConnection(parseMysqlConfig()); + try { + const [roleRows] = await conn.query( + `SELECT capability_key, allowed + FROM h5_capability_grants + WHERE subject_type = 'role' + AND subject_id = 'user' + AND capability_key IN ('aider', 'openhands') + ORDER BY capability_key`, + ); + const roleDefaults = Object.fromEntries( + roleRows.map((row) => [row.capability_key, Boolean(row.allowed)]), + ); + + const [userRows] = await conn.query( + `SELECT subject_id, capability_key, allowed + FROM h5_capability_grants + WHERE subject_type = 'user' + AND capability_key IN ('aider', 'openhands') + AND allowed = 1 + ORDER BY subject_id, capability_key`, + ); + + const grantsByUser = new Map(); + for (const row of userRows) { + if (!grantsByUser.has(row.subject_id)) grantsByUser.set(row.subject_id, []); + grantsByUser.get(row.subject_id).push(row.capability_key); + } + return { + roleDefaults, + whitelistUserCount: grantsByUser.size, + sampledUsers: [...grantsByUser.entries()].slice(0, 10).map(([userId, grantedTools]) => ({ + userId, + grantedTools: grantedTools.sort(), + chatHasCodeTools: false, + codeHasCodeTools: grantedTools.includes('aider') || grantedTools.includes('openhands'), + })), + }; + } finally { + await conn.end(); + } +} + +const envFile = process.env.MEMIND_ENV_FILE || path.join(process.cwd(), '.env'); +loadEnvFile(envFile); + +const distRoot = path.resolve(process.env.MEMIND_H5_DIST_ROOT || path.join(process.cwd(), 'dist')); +const publicBase = (process.env.H5_PUBLIC_BASE_URL || 'https://mm.tkmind.cn').replace(/\/$/, ''); +const insecureDispatcher = new Agent({ connect: { rejectUnauthorized: false } }); + +async function fetchJson(pathname) { + const res = await undiciFetch(`${publicBase}${pathname}`, { + dispatcher: publicBase.startsWith('https://127.0.0.1') ? insecureDispatcher : undefined, + }); + const text = await res.text(); + let json = null; + try { + json = JSON.parse(text); + } catch { + json = null; + } + return { ok: res.ok, status: res.status, json, text: json ? undefined : text.slice(0, 160) }; +} + +const frontend = checkFrontendBundle(distRoot); +const runtime = await fetchJson('/api/runtime/status').catch((err) => ({ ok: false, error: err.message })); +const toolGrants = await queryToolGrants().catch((err) => ({ + ok: false, + error: err instanceof Error ? (err.message || err.code || err.name) : String(err), +})); + +const buildFlags = { + enabledEnv: process.env.VITE_AGENT_CODE_RUNS_ENABLED ?? null, + autodetectEnv: process.env.VITE_AGENT_CODE_RUNS_AUTODETECT ?? null, + enabledTruthy: truthy(process.env.VITE_AGENT_CODE_RUNS_ENABLED), + autodetectTruthy: truthy(process.env.VITE_AGENT_CODE_RUNS_AUTODETECT), + note: 'These env values describe the current shell, not necessarily the already-built dist.', +}; + +const runtimePolicy = runtime.json?.toolRuntime ?? {}; +const roleDefaults = toolGrants.roleDefaults ?? {}; +const ok = Boolean( + frontend.jsAssetCount > 0 && + frontend.hasAgentRunEndpoint && + frontend.hasToolModePayload && + runtime.ok && + runtimePolicy.defaultMode === 'chat' && + runtimePolicy.codeToolMode === 'code' && + runtimePolicy.chatInjectsCodeTools === false && + roleDefaults.aider === false && + roleDefaults.openhands === false && + (toolGrants.sampledUsers ?? []).every((user) => user.chatHasCodeTools === false) && + (toolGrants.sampledUsers ?? []).every((user) => user.codeHasCodeTools === true) +); + +console.log(JSON.stringify({ + ok, + checkedAt: new Date().toISOString(), + publicBase, + envFile, + frontend, + buildFlags, + runtimePolicy, + toolGrants, + writes: { + database: false, + mindSpace: false, + createsAgentRun: false, + }, +}, null, 2)); + +process.exit(ok ? 0 : 1);