feat(workflow): 切换 WSL Bash 执行链
This commit is contained in:
parent
b40e2a26f9
commit
f9189d5e1e
29
README.md
29
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 <version>` 切换到目标版本;不要先扫盘找 `node.exe` 路径。
|
||||
如果 Debian shell 内的 Node/npm/pnpm 版本不符合 `engines`,先执行 `nvm ls` 查看已安装版本,再用 `nvm use <version>` 切换到目标版本;不要引用 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 <Admin登录页>` 固化登录态,输出的 `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` 预览记录,再决定是否写入。
|
||||
|
||||
@ -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",
|
||||
]);
|
||||
});
|
||||
|
||||
@ -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";
|
||||
|
||||
135
src/__tests__/unit/bashRuntime.test.ts
Normal file
135
src/__tests__/unit/bashRuntime.test.ts
Normal file
@ -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);
|
||||
}
|
||||
});
|
||||
@ -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,
|
||||
|
||||
@ -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",
|
||||
);
|
||||
|
||||
@ -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([
|
||||
|
||||
@ -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 前台展示',
|
||||
|
||||
@ -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<Record<typeof key, unknown>>)[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<ExecResult> {
|
||||
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<ExecResult> {
|
||||
return tryExecFile('bash', ['-lc', command], cwd, timeoutMs);
|
||||
}
|
||||
|
||||
@ -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'),
|
||||
|
||||
@ -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<string, unknown> {
|
||||
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 <seconds> node <<'NODE' ... NODE。",
|
||||
'同一卡点最多两次原样尝试;第二次仍卡住先固化再继续。',
|
||||
'第三次原样尝试禁止执行;必须改用已固化入口、固定脚本、拆分只读预检或直接接口 smoke。',
|
||||
'只清理本轮启动的进程或临时文件,不批量杀未知进程。',
|
||||
|
||||
@ -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<string, unknown> {
|
||||
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 <pid> 结束。',
|
||||
`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,
|
||||
|
||||
@ -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<DeployObservationResult> {
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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<Record<string, unknown>> {
|
||||
@ -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<string, string> = {
|
||||
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<Record<string, unknown>> {
|
||||
@ -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<string, unknown> {
|
||||
@ -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 校验",
|
||||
|
||||
@ -5,6 +5,7 @@ export type ProjectAlias =
|
||||
| 'fnosK8s'
|
||||
| 'knife4j'
|
||||
| 'mcp'
|
||||
| 'napcat'
|
||||
| 'playground'
|
||||
| 'root'
|
||||
| 'web';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user