feat(workflow): 切换 WSL Bash 执行链

This commit is contained in:
sunlei 2026-07-16 13:08:22 +08:00
parent b40e2a26f9
commit f9189d5e1e
17 changed files with 397 additions and 60 deletions

View File

@ -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` 的顶层 resultresult 为空才视为未完成。
- 生成专项组件工作流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 BashNode、依赖和工作区路径都留在 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` 预览记录,再决定是否写入。

View File

@ -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",
]);
});

View File

@ -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";

View 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);
}
});

View File

@ -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,

View File

@ -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",
);

View File

@ -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([

View File

@ -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 前台展示',

View File

@ -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);
}

View File

@ -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'),

View File

@ -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 heredoctimeout <seconds> node <<'NODE' ... NODE。",
'同一卡点最多两次原样尝试;第二次仍卡住先固化再继续。',
'第三次原样尝试禁止执行;必须改用已固化入口、固定脚本、拆分只读预检或直接接口 smoke。',
'只清理本轮启动的进程或临时文件,不批量杀未知进程。',

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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;
}

View File

@ -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 校验",

View File

@ -5,6 +5,7 @@ export type ProjectAlias =
| 'fnosK8s'
| 'knife4j'
| 'mcp'
| 'napcat'
| 'playground'
| 'root'
| 'web';