From f9189d5e1e720bb128022017898a88e9e99986ed Mon Sep 17 00:00:00 2001 From: sunlei Date: Thu, 16 Jul 2026 13:08:22 +0800 Subject: [PATCH] =?UTF-8?q?feat(workflow):=20=E5=88=87=E6=8D=A2=20WSL=20Ba?= =?UTF-8?q?sh=20=E6=89=A7=E8=A1=8C=E9=93=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 29 ++-- src/__tests__/runnerArgs.test.ts | 9 +- src/__tests__/unit.index.test.ts | 1 + src/__tests__/unit/bashRuntime.test.ts | 135 +++++++++++++++++++ src/__tests__/unit/deployObservation.test.ts | 23 +++- src/__tests__/unit/napcatAutomation.test.ts | 18 +++ src/__tests__/unit/reviewGovernance.test.ts | 19 +++ src/core/constants.ts | 2 + src/core/exec.ts | 30 ++++- src/registerTools.ts | 2 +- src/tools/blocker.ts | 7 +- src/tools/cleanup.ts | 26 +++- src/tools/deployObservation.ts | 66 ++++++--- src/tools/napcatAutomation.ts | 19 ++- src/tools/review.ts | 13 +- src/tools/workflow.ts | 57 +++++++- src/types.ts | 1 + 17 files changed, 397 insertions(+), 60 deletions(-) create mode 100644 src/__tests__/unit/bashRuntime.test.ts diff --git a/README.md b/README.md index 58839c4..f7da257 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ - 生成远程只读健康检查和数据库同步安全向导:覆盖飞牛 NAS 服务探测、GTID、`.kt-workspace/db-sync` 转储、备份库和行数校验。 - 生成或执行部署观测:把 Jenkins build、`build.xml` SCM revision/top-level result、日志尾部、K8s Deployment、Pod、`/health/runtime` 和任务 smoke 汇总成 `.kt-workspace/test-artifacts/deploy-observation` 下的运行态证据;Jenkins 状态优先取日志尾部最终 `Finished:`,没有最终行时回退到 `build.xml` 的顶层 result,result 为空才视为未完成。 - 生成专项组件工作流:KtTable、BlogArgon、AdminAuth、QQBot、FF14Plugin、NapCatLogin、SystemLog、Knife4jSwagger、FnosK8s 的防踩坑清单和验证点。 -- 生成验证进程清理计划:按项目路径和端口给出 PowerShell 检查命令,不直接杀进程。 +- 生成验证进程清理计划:按项目路径和端口给出 Debian Bash 的 `ps` / `ss` 只读检查命令,不直接杀进程。 - 清理历史产物:统一治理 `.kt-workspace` 下的测试/验证产物,按目录最近修改时间只保留最近 3 轮,模板目录永久保留;CLI 默认 dry-run,真实清理必须显式传 `--execute`。 - 检查 env 策略和变更风险:区分后端真实 env 与前端客户端 `.env*`,提醒锁文件、核心表格组件、API 时间序列化 KtDateTime 列/DTO 装饰器入口、部署链路和 Vue TSX 插槽写法等高风险改动。 - 全局 CodeReview 只读扫描:覆盖多仓 Git/敏感信息/冲突/调试输出、QQBot/NapCat 已固化回归点、Blog Live2D 语义 consumer 与反混淆清单、根目录生成物和 `TASKS.md` 结构。Live2D 旧 global 发布迁移态规则已退役;永久约束由全树 `@ts-nocheck`/短名 consumer 检查和独立 runtime coverage audit 共同承担。默认只扫描变更文件,并用 `--untracked-files=all` 展开未跟踪目录;`docs/`、README、Markdown 与 skill 只作为文档证据,不参与 runtime-debug 判定,源码目录中的 JSON 仍受扫描;任何文件改动后都要运行。 @@ -33,7 +33,7 @@ ## 安装 ```bash -cd D:/MyFiles/KT/mcp/ktWorkflow +cd /home/yemu2/KT/mcp/ktWorkflow pnpm install pnpm run typecheck pnpm run self-test @@ -54,7 +54,7 @@ pnpm run deploy-observation -- --project api --job KT-Template/KT-Template-API/m pnpm run admin-login -- --url http://127.0.0.1:5999/#/auth/login ``` -如果本机 Node/npm/pnpm 版本不对,先执行 `nvm ls` 查看已安装版本,再用 `nvm use ` 切换到目标版本;不要先扫盘找 `node.exe` 路径。 +如果 Debian shell 内的 Node/npm/pnpm 版本不符合 `engines`,先执行 `nvm ls` 查看已安装版本,再用 `nvm use ` 切换到目标版本;不要引用 Windows Node 或 `/mnt/*` 下的平台依赖。 ## Jenkins 入口模板 @@ -68,21 +68,21 @@ pnpm run admin-login -- --url http://127.0.0.1:5999/#/auth/login ## MCP 客户端配置 -把下面配置加入支持 stdio MCP 的客户端配置里: +Windows 上的 stdio MCP 客户端通过 `wsl.exe` 启动 Debian Bash;Node、依赖和工作区路径都留在 WSL2: ```json { "mcpServers": { "ktWorkflow": { - "command": "node", + "command": "wsl.exe", "args": [ - "--import", - "file:///D:/MyFiles/KT/mcp/ktWorkflow/node_modules/tsx/dist/loader.mjs", - "D:/MyFiles/KT/mcp/ktWorkflow/src/server.ts" - ], - "env": { - "KT_WORKSPACE_ROOT": "D:/MyFiles/KT" - } + "-d", + "Debian", + "--", + "bash", + "-lc", + "cd /home/yemu2/KT/mcp/ktWorkflow && exec env KT_WORKSPACE_ROOT=/home/yemu2/KT node --import ./node_modules/tsx/dist/loader.mjs src/server.ts" + ] } } } @@ -163,8 +163,9 @@ pnpm run admin-login -- --url http://127.0.0.1:5999/#/auth/login | 别名 | 路径 | | ------------ | ----------------------------------- | -| `root` | `D:/MyFiles/KT` | +| `root` | `/home/yemu2/KT` | | `mcp` | `mcp/ktWorkflow` | +| `napcat` | `GitHub/NapCatQQ` | | `api` | `Node/kt-template-online-api` | | `admin` | `Vue/kt-template-admin` | | `blog` | `Vue/kt-blog-web` | @@ -208,7 +209,7 @@ pnpm run admin-login -- --url http://127.0.0.1:5999/#/auth/login - 页面测试前调用 `kt_create_page_test_case`,再执行 Playwright/浏览器测试。 - Admin 页面测试前可先执行 `pnpm run admin-login -- --url ` 固化登录态,输出的 `storageState` 可作为后续 Playwright 用例前置状态。 - 接口改动后调用 `kt_api_test_plan`,并真实请求一次接口。 -- 验证启动过本地服务后调用 `kt_cleanup_process_plan`,清掉本次进程。 +- 验证启动过本地服务后调用 `kt_cleanup_process_plan`,用只读计划核对路径、端口和 PID,再只结束本轮启动的进程。 - 每轮测试/验证结束后先调用 `kt_cleanup_history`,或运行 `pnpm run cleanup-history -- --dry-run`;如果预览里 `deleted` 非空,确认范围后执行 `pnpm run cleanup-history -- --execute`,再复跑 dry-run 确认 `deleted=0`,让 `.kt-workspace` 历史产物只保留最近 3 轮并保留模板目录。Pio Live2D 的 `runtime-publish`、`runtime-export-moc-driven`、`probe-source-wordpress-parity`、`moc-runtime` 等活跃发布/基准输入会被保留,不按历史轮次删除。`kt_workstream_closeout` / `kt_workflow_loop_audit` 只认可明确的 `deleted=0` / `deleted=[]` 文本证据或结构化 `cleanupFinalDeleted=0`。 - 更新 Obsidian 图谱、模块页、文档矩阵或 `.obsidian` 配置后运行 `kt_obsidian_validate` / `pnpm run obsidian-validate`;整理工作流入口时再跑 `kt_obsidian_sync`。 - 改完文件后用 `kt_append_task_record` 先 `dryRun` 预览记录,再决定是否写入。 diff --git a/src/__tests__/runnerArgs.test.ts b/src/__tests__/runnerArgs.test.ts index a3b5900..4a35bd0 100644 --- a/src/__tests__/runnerArgs.test.ts +++ b/src/__tests__/runnerArgs.test.ts @@ -3,12 +3,13 @@ import test from "node:test"; import { buildSelfTestNodeArgs } from "../selfTestRunner.js"; -test("builds a Windows-safe explicit node:test command without shell globbing", () => { - assert.deepEqual(buildSelfTestNodeArgs("D:/repo"), [ +/** Verifies self-test child arguments stay explicit and POSIX-native without shell globbing. */ +test("builds a Debian-safe explicit node:test command without shell globbing", () => { + assert.deepEqual(buildSelfTestNodeArgs("/home/yemu2/KT/mcp/ktWorkflow"), [ "--import", - "file:///D:/repo/node_modules/tsx/dist/loader.mjs", + "file:///home/yemu2/KT/mcp/ktWorkflow/node_modules/tsx/dist/loader.mjs", "--test", "--test-concurrency=1", - "D:\\repo\\src\\__tests__\\index.test.ts", + "/home/yemu2/KT/mcp/ktWorkflow/src/__tests__/index.test.ts", ]); }); diff --git a/src/__tests__/unit.index.test.ts b/src/__tests__/unit.index.test.ts index 02a6580..55aa58d 100644 --- a/src/__tests__/unit.index.test.ts +++ b/src/__tests__/unit.index.test.ts @@ -1,6 +1,7 @@ import "./architecture.test.js"; import "./runnerArgs.test.js"; import "./unit/cli.test.js"; +import "./unit/bashRuntime.test.js"; import "./unit/deployObservation.test.js"; import "./unit/guardrailsAndVerification.test.js"; import "./unit/napcatAutomation.test.js"; diff --git a/src/__tests__/unit/bashRuntime.test.ts b/src/__tests__/unit/bashRuntime.test.ts new file mode 100644 index 0000000..b045628 --- /dev/null +++ b/src/__tests__/unit/bashRuntime.test.ts @@ -0,0 +1,135 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; + +import { tryBash } from "../../core/exec.js"; +import { buildBlockerResolution } from "../../tools/blocker.js"; +import { createCleanupProcessPlan } from "../../tools/cleanup.js"; +import { buildDeployObservationCommands } from "../../tools/deployObservation.js"; +import { buildNasCodexBootstrapPlan } from "../../tools/napcatAutomation.js"; +import { + buildComponentWorkflow, + buildRemoteHealthCheck, +} from "../../tools/workflow.js"; + +/** + * Parses generated command text with Bash without executing it. + * @param command - Copy-pastable Bash plan to validate. + * @returns Nothing when Bash accepts the command syntax. + */ +function assertBashSyntax(command: string): void { + const result = spawnSync("bash", ["-n"], { + encoding: "utf8", + input: command, + timeout: 5_000, + }); + assert.equal(result.status, 0, result.stderr); +} + +/** Verifies the shared execution helper runs commands in bounded Debian Bash. */ +test("executes trusted command text through Bash", async () => { + const result = await tryBash( + `printf 'bash-runtime:%s' "$PWD"`, + process.cwd(), + 5_000, + ); + + assert.equal(result.ok, true, result.stderr); + assert.match(result.stdout, /^bash-runtime:/); +}); + +/** Verifies remote health plans are bounded Bash commands with quoted SSH arguments. */ +test("builds bounded Bash remote health commands", async () => { + const plan = await buildRemoteHealthCheck({ + execute: false, + port: 2202, + services: ["ssh", "docker"], + sshTarget: "nas", + }); + const commands = ( + plan.commands as Array<{ command: string; service: string }> + ).map((item) => item.command); + const commandText = commands.join("\n"); + + assert.equal(commands.length, 2); + assert.match( + commandText, + /timeout --signal=TERM --kill-after=5s 30s ssh/, + ); + assert.match(commandText, /-o ConnectTimeout=10/); + assert.match(commandText, /-o BatchMode=yes/); + assert.match(commandText, /-o StrictHostKeyChecking=yes/); + assert.match(commandText, /-p 2202 'nas'/); + assert.doesNotMatch(commandText, /PowerShell|pwsh/i); + await assert.rejects( + buildRemoteHealthCheck({ + execute: false, + sshTarget: "-oProxyCommand=unexpected", + }), + /Unsafe remote health SSH target/, + ); +}); + +/** Verifies cleanup plans only inspect Debian processes and listening ports. */ +test("builds non-destructive Debian cleanup checks", () => { + const plan = createCleanupProcessPlan({ + ports: [5173], + project: "mcp", + }); + const commandText = (plan.commands as string[]).join("\n"); + + assert.match(commandText, /ps -eo pid=,comm=,args=/); + assert.match(commandText, /ss -lptn 'sport = :5173'/); + assert.doesNotMatch( + commandText, + /Get-CimInstance|Get-NetTCPConnection|Stop-Process|\b(?:p?kill)\b/i, + ); +}); + +/** Verifies active guidance points to Bash scripts and quoted Node heredocs. */ +test("uses Bash guidance for QQBot and blocker plans", () => { + const workflow = buildComponentWorkflow({ target: "QQBot" }); + const files = (workflow.workflow as { files: string[] }).files; + const blocker = buildBlockerResolution({}); + const safeCommandRules = blocker.safeCommandRules as string[]; + + assert.equal( + files.includes("ci/fnos-k8s/deploy-qqbot-runtime.sh"), + true, + ); + assert.equal(files.some((file) => file.endsWith(".ps1")), false); + assert.equal( + safeCommandRules.some((rule) => rule.includes("quoted heredoc")), + true, + ); + assert.equal( + safeCommandRules.some((rule) => /PowerShell|here-string/i.test(rule)), + false, + ); +}); + +/** Verifies every generated SSH command is syntactically valid when pasted into Bash. */ +test("parses generated Bash and SSH command plans", async () => { + const deployCommand = buildDeployObservationCommands({ execute: false })[0] + ?.command; + const bootstrapCommands = buildNasCodexBootstrapPlan({ + execute: true, + }).commands.map((item) => item.command); + const remoteHealth = await buildRemoteHealthCheck({ + execute: false, + services: ["docker"], + sshTarget: "nas", + }); + const remoteHealthCommands = ( + remoteHealth.commands as Array<{ command: string }> + ).map((item) => item.command); + + assert.ok(deployCommand); + for (const command of [ + deployCommand, + ...bootstrapCommands, + ...remoteHealthCommands, + ]) { + assertBashSyntax(command); + } +}); diff --git a/src/__tests__/unit/deployObservation.test.ts b/src/__tests__/unit/deployObservation.test.ts index 5bf55ed..c13d02d 100644 --- a/src/__tests__/unit/deployObservation.test.ts +++ b/src/__tests__/unit/deployObservation.test.ts @@ -304,6 +304,7 @@ test("fails smoke, replica, container, and missing-smoke evidence independently" assert.equal(noSmokeDetails.success, false); }); +/** Verifies the deploy dry-run is a directly executable, bounded Bash heredoc. */ test("builds a bounded dry-run command with Jenkins and temporary K8s evidence handling", async () => { const result = await buildDeployObservation({ deployment: "kt-template-online-api", @@ -317,7 +318,16 @@ test("builds a bounded dry-run command with Jenkins and temporary K8s evidence h const command = result.commands.map((item) => item.command).join("\n"); assert.equal(result.execute, false); - assert.match(command, /tr -d '\\015' \| bash -s/); + assert.match(command, /timeout --signal=TERM --kill-after=5s 175s ssh/); + assert.match(command, /-o BatchMode=yes/); + assert.match(command, /-o StrictHostKeyChecking=yes/); + assert.match(command, /<<'KT_DEPLOY_OBSERVATION'/); + assert.match( + command, + /'timeout --signal=TERM --kill-after=5s 165s bash -s'/, + ); + assert.match(command, /\nKT_DEPLOY_OBSERVATION$/); + assert.doesNotMatch(command, /\$script|tr -d|PowerShell|here-string/i); assert.match(command, /kubectl/); assert.match(command, /http:\/\/127\.0\.0\.1:48085\/health\/runtime/); assert.match(command, /curl -fsS --max-time 8/); @@ -402,7 +412,18 @@ test("keeps unsafe project text out of dry-run commands", async () => { assert.doesNotMatch(JSON.stringify(result.commands), /\.\.\/escape/); }); +/** Verifies shell quoting accepts literal text while rejecting command-boundary injection. */ test("rejects unsafe smoke, health URL, SSH target, and structured input", async () => { + const quotedLiteral = await buildDeployObservation({ + execute: false, + jobName: "job'@name", + }); + assert.equal( + quotedLiteral.commands[0]?.command.includes( + `JOB_NAME='job'"'"'@name'`, + ), + true, + ); await assert.rejects( buildDeployObservation({ execute: false, diff --git a/src/__tests__/unit/napcatAutomation.test.ts b/src/__tests__/unit/napcatAutomation.test.ts index 9ae9a04..e7e98bf 100644 --- a/src/__tests__/unit/napcatAutomation.test.ts +++ b/src/__tests__/unit/napcatAutomation.test.ts @@ -388,9 +388,21 @@ test("keeps NAS Codex bootstrap bounded to trusted Linux preparation", () => { "codex --version", "test -d /vol1/docker/kt-codex/workspace/KT", "test -d /vol1/docker/kt-codex/home/.codex", + "timeout --signal=TERM --kill-after=5s 130s ssh", + "-o BatchMode=yes", + "-o ConnectTimeout=10", + "-o StrictHostKeyChecking=yes", + "'timeout --signal=TERM --kill-after=5s 120s bash -s'", + "<<'KT_NAS_CODEX_BOOTSTRAP'", ], "NAS bootstrap dry-run", ); + assert.match(dryRunText, /\nKT_NAS_CODEX_BOOTSTRAP$/); + assertExcludesAll( + dryRunText, + ["$remoteScript", "tr -d", "PowerShell", "here-string"], + "NAS bootstrap dry-run", + ); assert.ok(dryRun.commands.every((command) => command.readOnly)); assert.ok(!dryRunText.includes("C:\\Users")); @@ -409,6 +421,8 @@ test("keeps NAS Codex bootstrap bounded to trusted Linux preparation", () => { 'nvm alias default "$NODE_VERSION"', "npm install -g @openai/codex@latest", 'ln -sfn "$target" "/usr/local/bin/$cmd"', + "timeout --signal=TERM --kill-after=5s 310s ssh", + "'timeout --signal=TERM --kill-after=5s 300s bash -s'", ], "NAS bootstrap execute plan", ); @@ -425,6 +439,10 @@ test("keeps NAS Codex bootstrap bounded to trusted Linux preparation", () => { "cp ~/.codex", "scp ~/.codex", "C:\\Users", + "$remoteScript", + "tr -d", + "PowerShell", + "here-string", ], "NAS bootstrap execute plan", ); diff --git a/src/__tests__/unit/reviewGovernance.test.ts b/src/__tests__/unit/reviewGovernance.test.ts index 1185f0a..10b7cf3 100644 --- a/src/__tests__/unit/reviewGovernance.test.ts +++ b/src/__tests__/unit/reviewGovernance.test.ts @@ -6,6 +6,7 @@ import { resolveInsideRoot } from "../../core/workspace.js"; import { findTaskRecordGovernanceFindings, isBenignCredentialReviewValue, + isAllowedSensitiveTrackedFile, } from "../../tools/review.js"; interface CategorizedFinding { @@ -42,6 +43,24 @@ test("classifies credential review values without localized-label false positive ); }); +/** Verifies tracked frontend env exceptions stay limited to public frontend project boundaries. */ +test("allows frontend env files without suppressing backend or adjacent NapCat env files", () => { + assert.equal(isAllowedSensitiveTrackedFile("admin", ".env.production"), true); + assert.equal( + isAllowedSensitiveTrackedFile( + "napcat", + "packages/napcat-webui-frontend/.env", + ), + true, + ); + assert.equal( + isAllowedSensitiveTrackedFile("napcat", "packages/napcat-develop/.env"), + false, + ); + assert.equal(isAllowedSensitiveTrackedFile("api", ".env"), false); + assert.equal(isAllowedSensitiveTrackedFile("api", ".env.example"), true); +}); + /** Verifies exact recent-record fields and the three-record cap in TASKS.md. */ test("enforces TASKS recent-record shape and count", () => { const extraFieldFindings = findTaskRecordGovernanceFindings([ diff --git a/src/core/constants.ts b/src/core/constants.ts index 2821f40..8f3e21e 100644 --- a/src/core/constants.ts +++ b/src/core/constants.ts @@ -6,6 +6,7 @@ export const projectAliases = { fnosK8s: 'Plugins/fnos-k8s-dashboard-fpk', knife4j: 'Plugins/knife4j-swagger-vue3', mcp: 'mcp/ktWorkflow', + napcat: 'GitHub/NapCatQQ', playground: 'Vue/kt-template-online-playground', root: '.', web: 'Vue/kt-template-online-web', @@ -18,6 +19,7 @@ export const projectLabels = { fnosK8s: 'fnOS K8s Dashboard FPK', knife4j: 'Knife4j Swagger Vue3 插件', mcp: 'KT Workflow MCP', + napcat: 'NapCatQQ Fork', playground: 'Playground 编辑器', root: 'KT 协作根目录', web: 'Web 前台展示', diff --git a/src/core/exec.ts b/src/core/exec.ts index 04d86fd..bb4f5a4 100644 --- a/src/core/exec.ts +++ b/src/core/exec.ts @@ -4,6 +4,13 @@ import { promisify } from 'node:util'; import type { ExecResult } from '../types.js'; const execFileAsync = promisify(execFile); + +/** + * Extracts text fields from Node child-process errors without leaking non-text values. + * @param error - Unknown error returned by `execFile`. + * @param key - Error field to read. + * @returns UTF-8 text for the selected field, or an empty string when unavailable. + */ function getErrorText(error: unknown, key: 'message' | 'stderr' | 'stdout'): string { const value = (error as Partial>)[key]; if (typeof value === 'string') return value; @@ -11,6 +18,14 @@ function getErrorText(error: unknown, key: 'message' | 'stderr' | 'stdout'): str return ''; } +/** + * Executes one program with an argument array and a finite timeout. + * @param command - Executable available on the current Debian PATH. + * @param args - Arguments passed without shell interpolation. + * @param cwd - Working directory for the child process. + * @param timeoutMs - Maximum runtime before Node terminates the child process. + * @returns Normalized stdout, stderr, and success status without throwing command failures. + */ export async function tryExecFile( command: string, args: string[], @@ -38,6 +53,17 @@ export async function tryExecFile( } } -export async function tryPowerShell(command: string, cwd: string, timeoutMs = 120_000): Promise { - return tryExecFile('powershell', ['-NoProfile', '-NonInteractive', '-Command', command], cwd, timeoutMs); +/** + * Runs a bounded command script through Debian Bash login-shell semantics. + * @param command - Bash command text generated by a trusted ktWorkflow builder. + * @param cwd - Working directory visible inside Debian WSL2. + * @param timeoutMs - Maximum runtime before Node terminates Bash. + * @returns Normalized stdout, stderr, and success status. + */ +export async function tryBash( + command: string, + cwd: string, + timeoutMs = 120_000, +): Promise { + return tryExecFile('bash', ['-lc', command], cwd, timeoutMs); } diff --git a/src/registerTools.ts b/src/registerTools.ts index 41a6a43..2f82c63 100644 --- a/src/registerTools.ts +++ b/src/registerTools.ts @@ -192,7 +192,7 @@ export function registerTools(server: McpServer): void { server.registerTool( 'kt_cleanup_process_plan', { - description: '生成按项目路径和端口清理验证进程的 PowerShell 检查命令,不直接杀进程。', + description: '生成按项目路径和端口检查验证进程的 Debian Bash 只读命令,不直接杀进程。', inputSchema: { ports: z.array(z.number().int().min(1).max(65_535)).default([]), project: z.string().default('root'), diff --git a/src/tools/blocker.ts b/src/tools/blocker.ts index 2ec09d0..b323718 100644 --- a/src/tools/blocker.ts +++ b/src/tools/blocker.ts @@ -11,6 +11,11 @@ function formatRecord( return `问题点:${input.problem};稳定解法:${input.solution};后续入口:${input.nextEntry};验证证据:${evidence}`; } +/** + * Builds a durable blocker record and safe next-step guidance without rerunning the failed command. + * @param input - Problem, stable solution, evidence, attempt count, and optional task-record write mode. + * @returns Blocker classification, retry stop condition, command safety rules, and optional write result. + */ export function buildBlockerResolution(input: BlockerResolutionInput = {}): Record { const attempts = input.attempts ?? 1; const project = resolveProject(input.project || 'root'); @@ -65,7 +70,7 @@ export function buildBlockerResolution(input: BlockerResolutionInput = {}): Reco safeCommandRules: [ '本地或远程长命令必须有外层 timeout 或固定脚本内置超时。', '远程 here-doc、awk、kubectl jsonpath 和 Node 片段不能直接塞进双引号 ssh 命令。', - "PowerShell 多行 Node 片段使用单引号 here-string:@' ... '@ | node -。", + "Debian WSL2 多行 Node 片段使用 quoted heredoc:timeout node <<'NODE' ... NODE。", '同一卡点最多两次原样尝试;第二次仍卡住先固化再继续。', '第三次原样尝试禁止执行;必须改用已固化入口、固定脚本、拆分只读预检或直接接口 smoke。', '只清理本轮启动的进程或临时文件,不批量杀未知进程。', diff --git a/src/tools/cleanup.ts b/src/tools/cleanup.ts index 27a7c7e..273a2c7 100644 --- a/src/tools/cleanup.ts +++ b/src/tools/cleanup.ts @@ -4,23 +4,37 @@ import path from 'node:path'; import type { CleanupHistoryInput, CleanupProcessPlanInput } from '../types.js'; import { defaultHistoryRoots } from '../core/constants.js'; import { resolveInsideRoot, resolveProject, toPosix, workspaceRoot } from '../core/workspace.js'; + +/** + * Quotes one value as a literal POSIX shell argument. + * @param value - Raw value that must remain one shell token. + * @returns A single-quoted token with embedded quotes escaped. + */ +function shellSingleQuote(value: string): string { + return `'${value.replaceAll("'", "'\"'\"'")}'`; +} + +/** + * Builds read-only Debian process and listening-port checks for one KT project. + * @param input - Project alias/path and optional ports used to narrow validation processes. + * @returns A non-destructive command plan; callers must terminate only PIDs they started and verified. + */ export function createCleanupProcessPlan(input: CleanupProcessPlanInput): Record { const project = resolveProject(input.project); - const escapedProjectPath = project.path.replaceAll('\\', '\\\\'); const portFilters = (input.ports || []).map((port) => ({ - command: `Get-NetTCPConnection -LocalPort ${port} -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess -Unique`, + command: `ss -lptn 'sport = :${port}'`, port, })); return { commands: [ - `Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like '*${escapedProjectPath}*' -and $_.Name -match 'node|pnpm|npm|yarn' } | Select-Object ProcessId,Name,CommandLine`, - '确认命中进程只属于本次验证后,再用 Stop-Process -Id 结束。', + `ps -eo pid=,comm=,args= | awk -v project=${shellSingleQuote(project.path)} 'index($0, project) > 0 && $2 ~ /^(node|npm|pnpm|yarn|vite)$/ { print }'`, ...portFilters.map((item) => item.command), ], notes: [ - '先按项目路径筛选,再按端口交叉确认。', - '不要批量结束与本次验证无关的 Node 进程。', + '这些命令只读;先按项目路径筛选,再用 ss 按端口交叉确认。', + '优先使用启动验证服务时记录的 PID,只结束本轮明确启动并已核对的进程。', + '不要使用 pkill node、pkill vite 或命令行模糊匹配批量结束进程。', '页面测试结束后清理浏览器和 Vite/Nest 进程,避免内存累积。', ], project, diff --git a/src/tools/deployObservation.ts b/src/tools/deployObservation.ts index bb35ebd..7050bf9 100644 --- a/src/tools/deployObservation.ts +++ b/src/tools/deployObservation.ts @@ -13,7 +13,7 @@ import type { DeployObservationRuntimeHealthEvidence, DeployObservationStatus, } from '../types.js'; -import { tryPowerShell } from '../core/exec.js'; +import { tryBash } from '../core/exec.js'; import { formatDateInShanghai, resolveInsideRoot, @@ -21,7 +21,11 @@ import { } from '../core/workspace.js'; const defaultArtifactRoot = '.kt-workspace/test-artifacts/deploy-observation'; -const unsafeHereStringPattern = /[\r\n]|'@|"@/; +const deployObservationHeredoc = 'KT_DEPLOY_OBSERVATION'; +const deployObservationExecutionTimeoutMs = 180_000; +const deployObservationLocalTimeoutSeconds = 175; +const deployObservationRemoteTimeoutSeconds = 165; +const unsafeStructuredInputPattern = /[\0\r\n]/; interface NormalizedInput extends Required< @@ -70,11 +74,17 @@ function normalizeArtifactRoot(value?: string): string { return artifactRoot; } +/** + * Rejects structured values that could break generated Bash command boundaries. + * @param field - Input field name used in the validation error. + * @param value - Optional value embedded into a shell-quoted assignment or argument. + * @returns Nothing when the value is safe. + */ function assertSafeStructuredInput(field: string, value?: string): void { if (!value) return; - if (unsafeHereStringPattern.test(value)) { + if (unsafeStructuredInputPattern.test(value)) { throw new Error( - `Unsafe deploy observation input "${field}": CR/LF and PowerShell here-string terminators are not allowed.`, + `Unsafe deploy observation input "${field}": NUL and line breaks are not allowed.`, ); } } @@ -196,15 +206,15 @@ function normalizeInput(input: DeployObservationInput = {}): NormalizedInput { }; } +/** + * Quotes one value as a literal POSIX shell argument. + * @param value - Raw value that must remain one shell token. + * @returns A single-quoted token with embedded quotes escaped. + */ function shellSingleQuote(value: string): string { return `'${value.replaceAll("'", "'\"'\"'")}'`; } -function powerShellArgument(value: string): string { - if (/^[A-Za-z0-9._@:/-]+$/.test(value)) return value; - return `'${value.replaceAll("'", "''")}'`; -} - function numberOrNull(value: unknown): number | null { return typeof value === 'number' && Number.isFinite(value) ? value : null; } @@ -348,21 +358,36 @@ function buildRemoteScript(input: NormalizedInput): string { ].join('\n'); } +/** + * Builds a directly executable, bounded Bash heredoc that streams read-only probes to NAS SSH. + * @param input - Deploy observation target and optional SSH overrides. + * @returns Command descriptors suitable for dry-run display or bounded execution. + */ export function buildDeployObservationCommands( input: DeployObservationInput = {}, ): DeployObservationCommand[] { const normalized = normalizeInput(input); const sshParts = [ + `timeout --signal=TERM --kill-after=5s ${deployObservationLocalTimeoutSeconds}s`, 'ssh', + '-o', + 'BatchMode=yes', + '-o', + 'ConnectTimeout=10', + '-o', + 'ConnectionAttempts=1', + '-o', + 'StrictHostKeyChecking=yes', ...(normalized.sshPort ? ['-p', String(normalized.sshPort)] : []), - powerShellArgument(normalized.sshTarget), - '"tr -d \'\\015\' | bash -s"', + shellSingleQuote(normalized.sshTarget), + shellSingleQuote( + `timeout --signal=TERM --kill-after=5s ${deployObservationRemoteTimeoutSeconds}s bash -s`, + ), ]; const command = [ - "$script = @'", + `${sshParts.join(' ')} <<'${deployObservationHeredoc}'`, buildRemoteScript(normalized), - "'@", - `$script | ${sshParts.join(' ')}`, + deployObservationHeredoc, ].join('\n'); return [ @@ -713,6 +738,11 @@ function writeEvidenceArtifact( return filePath; } +/** + * Builds or executes the bounded Bash deployment observation and persists normalized evidence. + * @param input - Jenkins, Kubernetes, health, smoke, artifact, and execution options. + * @returns Dry-run commands or execution results with a local JSON evidence artifact. + */ export async function buildDeployObservation( input: DeployObservationInput = {}, ): Promise { @@ -728,7 +758,7 @@ export async function buildDeployObservation( ]; const notes = [ 'Default execute=false only returns the bounded read-only command.', - 'execute=true runs through ssh nas with CRLF-stripped here-string and writes local JSON evidence.', + 'execute=true runs a bounded quoted Bash heredoc through ssh nas and writes local JSON evidence.', 'Jenkins/K8s rollout evidence is deployment evidence; task smoke is still required for functional completion.', 'The command reads Jenkins build files, kubectl status, events, and HTTP health only; it does not read Secrets or mutate remote state.', ]; @@ -743,7 +773,11 @@ export async function buildDeployObservation( } const command = commands[0]?.command || ''; - const result = await tryPowerShell(command, workspaceRoot, 180_000); + const result = await tryBash( + command, + workspaceRoot, + deployObservationExecutionTimeoutMs, + ); const sectionMap = buildDeployObservationSectionMap(result.stdout); const evidence = normalizeDeployObservationEvidence({ input: normalized, diff --git a/src/tools/napcatAutomation.ts b/src/tools/napcatAutomation.ts index 4b6976d..f5d62a9 100644 --- a/src/tools/napcatAutomation.ts +++ b/src/tools/napcatAutomation.ts @@ -482,28 +482,33 @@ function resolveNasBootstrapServiceName(value: string): string { } /** - * Builds the bounded PowerShell-to-NAS here-string wrapper used by bootstrap plans. + * Builds the bounded Bash-to-NAS heredoc wrapper used by bootstrap plans. * @param bodyLines - Linux shell commands that will run on NAS only when a human executes the returned plan. * @param timeoutSeconds - Remote `timeout` budget that prevents hanging checks or installs. - * @returns A copy-pastable PowerShell command using the KT NAS CRLF-safe SSH pattern. + * @returns A copy-pastable Debian Bash command with bounded local SSH and remote script execution. */ function buildNasBootstrapSshPlanCommand( bodyLines: string[], timeoutSeconds: number, ): string { + const heredoc = 'KT_NAS_CODEX_BOOTSTRAP'; + const localTimeoutSeconds = timeoutSeconds + 10; + const remoteCommand = shellQuotePath( + `timeout --signal=TERM --kill-after=5s ${timeoutSeconds}s bash -s`, + ); + return [ - "$remoteScript = @'", + `timeout --signal=TERM --kill-after=5s ${localTimeoutSeconds}s ssh -o BatchMode=yes -o ConnectTimeout=10 -o ConnectionAttempts=1 -o StrictHostKeyChecking=yes nas ${remoteCommand} <<'${heredoc}'`, 'set -euo pipefail', ...bodyLines, - "'@", - `$remoteScript | ssh nas "tr -d '\\015' | timeout ${timeoutSeconds}s bash -s"`, + heredoc, ].join('\n'); } /** * Builds the read-only NAS Codex bootstrap dry-run command required by Task 6. * @param context - Trusted NAS workspace and CODEX_HOME paths. - * @returns A bounded SSH here-string containing Linux-only version and directory checks. + * @returns A bounded SSH heredoc containing Linux-only version and directory checks. */ function buildNasBootstrapDryRunCommand( context: NasCodexBootstrapContext, @@ -533,7 +538,7 @@ function buildNasBootstrapDryRunCommand( /** * Builds the explicit execute-mode NAS preparation plan without running it locally. * @param context - Trusted NAS workspace, Codex home, and service label context. - * @returns A bounded SSH here-string that creates only kt-codex directories and checks/installs CLI packages. + * @returns A bounded SSH heredoc that creates only kt-codex directories and checks/installs CLI packages. */ function buildNasBootstrapExecuteCommand( context: NasCodexBootstrapContext, diff --git a/src/tools/review.ts b/src/tools/review.ts index d83bb52..b2efebd 100644 --- a/src/tools/review.ts +++ b/src/tools/review.ts @@ -18,6 +18,7 @@ export const defaultReviewProjects = [ "web", "playground", "mcp", + "napcat", "knife4j", "fnosK8s", ] as const; @@ -1912,7 +1913,13 @@ function isCredentialHintAllowed(file: string, line: string): boolean { return /(^|\/)views\/(demos|examples)\//.test(normalized); } -function isAllowedSensitiveTrackedFile( +/** + * Distinguishes intentionally tracked public frontend env files from backend secret-bearing env files. + * @param projectAlias - Known KT project alias, or null for an arbitrary path. + * @param file - Repository-relative tracked file path. + * @returns True only for examples, frontend-only repositories, or NapCat's Vite frontend package. + */ +export function isAllowedSensitiveTrackedFile( projectAlias: ProjectAlias | null, file: string, ): boolean { @@ -1920,6 +1927,10 @@ function isAllowedSensitiveTrackedFile( if (projectAlias && frontendEnvProjectAliases.has(projectAlias)) { return /(^|\/)\.env($|\..+)/i.test(file); } + if (projectAlias === "napcat") { + const normalized = file.replaceAll("\\", "/"); + return /^packages\/napcat-webui-frontend\/\.env($|\.)/i.test(normalized); + } return false; } diff --git a/src/tools/workflow.ts b/src/tools/workflow.ts index bbbf1d1..f518bf6 100644 --- a/src/tools/workflow.ts +++ b/src/tools/workflow.ts @@ -9,7 +9,7 @@ import type { RepoType, ResolvedProject, } from "../types.js"; -import { tryExecFile, tryPowerShell } from "../core/exec.js"; +import { tryBash, tryExecFile } from "../core/exec.js"; import { detectRepoType, parseGitStatusFiles } from "../core/project.js"; import { getGlobalReviewCommand, @@ -35,6 +35,15 @@ interface RepoSnapshot { status: string; } +/** + * Quotes one value as a literal POSIX shell argument. + * @param value - Raw value that must remain one shell argument. + * @returns A single-quoted token with embedded quotes escaped. + */ +function shellSingleQuote(value: string): string { + return `'${value.replaceAll("'", "'\"'\"'")}'`; +} + function normalizeProjectKeys(projects?: string[]): string[] { const selected = (projects?.length ?? 0) > 0 ? projects! : [...defaultReviewProjects]; @@ -119,6 +128,11 @@ function suggestCommitMessage(snapshot: RepoSnapshot): string { return `chore(${snapshot.project.alias || "repo"}): 更新项目改动`; } +/** + * Builds the KT finish-task report and optionally runs its bounded validation commands in Bash. + * @param input - Selected projects, validation mode, review scope, and cleanup options. + * @returns Repository snapshots, validation results, review evidence, and closeout guidance. + */ export async function buildFinishTask( input: FinishTaskInput = {}, ): Promise> { @@ -147,7 +161,7 @@ export async function buildFinishTask( validationRuns.push({ command, project: plan.project, - result: await tryPowerShell(command, plan.project.path), + result: await tryBash(command, plan.project.path), }); } } @@ -293,18 +307,37 @@ export async function buildPushPlan( }; } +/** + * Builds bounded, read-only SSH health probes for the selected NAS services. + * @param input - SSH destination, port, and optional service names. + * @returns Copy-pastable Debian Bash commands paired with service labels. + */ function buildRemoteHealthCommands(input: RemoteHealthCheckInput = {}): Array<{ command: string; service: string; }> { - const host = + const host = ( input.sshTarget || input.host || process.env.KT_NAS_SSH_TARGET || - "nas.kwitsukasa.top"; + "nas.kwitsukasa.top" + ).trim(); const port = input.port ?? Number(process.env.KT_NAS_SSH_PORT || 2202); + if (!host || host.startsWith("-") || /[\0\r\n]/.test(host)) { + throw new Error("Unsafe remote health SSH target."); + } + if (!Number.isInteger(port) || port < 1 || port > 65_535) { + throw new Error("Unsafe remote health SSH port."); + } + /** Formats one read-only remote probe as a bounded local SSH command. */ const ssh = (remoteCommand: string) => - `ssh -p ${port} ${host} "${remoteCommand}"`; + [ + "timeout --signal=TERM --kill-after=5s 30s", + "ssh -o BatchMode=yes -o ConnectTimeout=10 -o ConnectionAttempts=1 -o StrictHostKeyChecking=yes", + `-p ${port}`, + shellSingleQuote(host), + shellSingleQuote(remoteCommand), + ].join(" "); const allCommands: Record = { docker: ssh("docker ps --format '{{.Names}} {{.Status}} {{.Ports}}'"), gitea: ssh( @@ -343,6 +376,11 @@ function buildRemoteHealthCommands(input: RemoteHealthCheckInput = {}): Array<{ })); } +/** + * Returns read-only NAS health commands and optionally executes them through bounded Debian Bash. + * @param input - SSH destination, service selection, and explicit execute flag. + * @returns Generated commands plus normalized execution results when requested. + */ export async function buildRemoteHealthCheck( input: RemoteHealthCheckInput = {}, ): Promise> { @@ -351,7 +389,7 @@ export async function buildRemoteHealthCheck( ? await Promise.all( commands.map(async (item) => ({ ...item, - result: await tryPowerShell(item.command, workspaceRoot), + result: await tryBash(item.command, workspaceRoot), })), ) : []; @@ -410,6 +448,11 @@ export function buildDbSyncPlan( }; } +/** + * Returns the stabilized guardrails and verification checklist for a specialist KT component. + * @param input - Component workflow target name. + * @returns Files, anti-patterns, guardrails, and verification items for that target. + */ export function buildComponentWorkflow( input: ComponentWorkflowInput = {}, ): Record { @@ -527,7 +570,7 @@ export function buildComponentWorkflow( files: [ "Node/kt-template-online-api/src/qqbot", "Vue/kt-template-admin/apps/web-antdv-next/src/views/qqbot", - "ci/fnos-k8s/deploy-qqbot-runtime.ps1", + "ci/fnos-k8s/deploy-qqbot-runtime.sh", ], guardrails: [ "OneBot 反向 WS token 校验", diff --git a/src/types.ts b/src/types.ts index 4d1b23b..307ee83 100644 --- a/src/types.ts +++ b/src/types.ts @@ -5,6 +5,7 @@ export type ProjectAlias = | 'fnosK8s' | 'knife4j' | 'mcp' + | 'napcat' | 'playground' | 'root' | 'web';