912 lines
31 KiB
TypeScript
912 lines
31 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { existsSync, readFileSync, rmSync } from "node:fs";
|
|
import path from "node:path";
|
|
import test, { after } from "node:test";
|
|
|
|
import {
|
|
parseNapcatRemoteDevHandoffCliArgs,
|
|
parseNapcatRuntimeReleaseReadinessCliArgs,
|
|
parseNapcatSyncCandidateReviewCliArgs,
|
|
parseNapcatUpstreamAuditCliArgs,
|
|
parseNasCodexBootstrapCliArgs,
|
|
} from "../../core/cli.js";
|
|
import { registeredToolNames } from "../../core/constants.js";
|
|
import { resolveInsideRoot } from "../../core/workspace.js";
|
|
import { registerTools } from "../../registerTools.js";
|
|
import {
|
|
loadNapcatAutomationPrompt,
|
|
type NapcatAutomationPromptName,
|
|
napcatAutomationPromptNames,
|
|
} from "../../tools/napcatAutomation.prompts.js";
|
|
import {
|
|
napcatAutomationReasonCodes,
|
|
parseNapcatAutomationClassification,
|
|
upstreamAuditOutputSchema,
|
|
} from "../../tools/napcatAutomation.types.js";
|
|
import {
|
|
buildNapcatCodexExecCommand,
|
|
buildNapcatRemoteDevHandoff,
|
|
buildNapcatRuntimeReleaseReadiness,
|
|
buildNapcatSyncCandidateReview,
|
|
buildNapcatUpstreamAudit,
|
|
buildNasCodexBootstrapPlan,
|
|
classifyNapcatUpstreamAudit,
|
|
napcatAutomationDefaults,
|
|
shouldFailNapcatAutomationCli,
|
|
} from "../../tools/napcatAutomation.js";
|
|
|
|
const NAPCAT_TEST_ARTIFACT_ROOT = resolveInsideRoot(
|
|
".kt-workspace/test-artifacts/napcat-automation-unit",
|
|
);
|
|
|
|
/**
|
|
* Removes the complete artifact namespace owned by this test module.
|
|
* @returns Nothing; cleanup is idempotent and never touches sibling evidence.
|
|
*/
|
|
function cleanupNapcatTestArtifacts(): void {
|
|
rmSync(NAPCAT_TEST_ARTIFACT_ROOT, { force: true, recursive: true });
|
|
}
|
|
|
|
after(cleanupNapcatTestArtifacts);
|
|
|
|
/**
|
|
* Reads a UTF-8 ktWorkflow fixture through the guarded KT workspace resolver.
|
|
* @param relativePath - Workspace-relative path to the source or CI template.
|
|
* @returns Normalized text with LF line endings for stable substring assertions.
|
|
*/
|
|
function readWorkspaceText(relativePath: string): string {
|
|
return readFileSync(resolveInsideRoot(relativePath), "utf8").replace(/\r\n/g, "\n");
|
|
}
|
|
|
|
/**
|
|
* Asserts that every required safety marker remains present in generated text.
|
|
* @param content - Command or template text under review.
|
|
* @param expectedValues - Required markers whose absence would weaken the contract.
|
|
* @param label - Human-readable contract name included in assertion diagnostics.
|
|
* @returns Nothing; the assertion fails on the first missing marker.
|
|
*/
|
|
function assertIncludesAll(
|
|
content: string,
|
|
expectedValues: readonly string[],
|
|
label: string,
|
|
): void {
|
|
for (const expected of expectedValues) {
|
|
assert.ok(content.includes(expected), `${label} missing: ${expected}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Asserts that mutation commands or unsafe configuration markers do not leak into text.
|
|
* @param content - Command or template text under review.
|
|
* @param forbiddenValues - Text fragments prohibited by the workflow boundary.
|
|
* @param label - Human-readable contract name included in assertion diagnostics.
|
|
* @returns Nothing; the assertion fails on the first forbidden marker.
|
|
*/
|
|
function assertExcludesAll(
|
|
content: string,
|
|
forbiddenValues: readonly string[],
|
|
label: string,
|
|
): void {
|
|
for (const forbidden of forbiddenValues) {
|
|
assert.ok(!content.includes(forbidden), `${label} leaked: ${forbidden}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Verifies that a NapCat Jenkins template uses the NAS node and mounted helper container.
|
|
* @param template - Full Jenkinsfile text.
|
|
* @param label - Template identity used in assertion diagnostics.
|
|
* @returns Nothing; assertions fail when the NAS execution topology regresses.
|
|
*/
|
|
function assertUsesNasWorkflowRunner(template: string, label: string): void {
|
|
assert.ok(
|
|
template.includes("agent { label 'kt-node-agent' }"),
|
|
`${label} must target kt-node-agent`,
|
|
);
|
|
assertIncludesAll(
|
|
template,
|
|
[
|
|
"KT_WORKFLOW_RUNNER_IMAGE",
|
|
"docker run --rm",
|
|
"/vol1/docker/kt-codex:/vol1/docker/kt-codex",
|
|
"COREPACK_HOME=/vol1/docker/kt-codex/runtime/corepack",
|
|
"corepack prepare pnpm@10.28.2 --activate",
|
|
],
|
|
`${label} NAS helper runner`,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Proves that the default upstream-audit Jenkins stage stays dry-run before Codex execution.
|
|
* @param template - Full upstream-sync Jenkinsfile text.
|
|
* @returns Nothing; assertions fail when stage ordering or safe flags regress.
|
|
*/
|
|
function assertUpstreamDefaultAuditIsDryRun(template: string): void {
|
|
const defaultStageStart = template.indexOf(
|
|
"stage('ktWorkflow upstream audit dry-run')",
|
|
);
|
|
const codexStageStart = template.indexOf(
|
|
"stage('ktWorkflow upstream audit with Codex')",
|
|
);
|
|
assert.notEqual(defaultStageStart, -1);
|
|
assert.notEqual(codexStageStart, -1);
|
|
assert.ok(defaultStageStart < codexStageStart);
|
|
|
|
const defaultStage = template.slice(defaultStageStart, codexStageStart);
|
|
assert.ok(defaultStage.includes("run napcat-upstream-audit --"));
|
|
assertExcludesAll(defaultStage, ["--execute", "--use-codex"], "default audit stage");
|
|
}
|
|
|
|
/** Verifies strict NapCat schemas, CLI parsing, and terminal failure classification. */
|
|
test("preserves strict NapCat automation schemas and CLI gates", () => {
|
|
assert.equal(
|
|
upstreamAuditOutputSchema.safeParse({
|
|
classification: "manual-review",
|
|
reasonCodes: ["HOT_ZONE_CHANGED"],
|
|
recommendedAction: "request-human-review",
|
|
summary: "Login hot-zone changed.",
|
|
}).success,
|
|
true,
|
|
);
|
|
assert.equal(
|
|
upstreamAuditOutputSchema.safeParse({
|
|
classification: "manual-review",
|
|
reasonCodes: ["HOT_ZONE_CHANGED"],
|
|
recommendedAction: "request-human-review",
|
|
summary: "Login hot-zone changed.",
|
|
unexpectedField: "Codex output schema rejects this.",
|
|
}).success,
|
|
false,
|
|
);
|
|
assert.ok(napcatAutomationReasonCodes.includes("HOT_ZONE_CHANGED"));
|
|
|
|
const upstreamCli = parseNapcatUpstreamAuditCliArgs([
|
|
"node",
|
|
"server",
|
|
"--napcat-upstream-audit",
|
|
"--execute",
|
|
"--use-codex",
|
|
"--artifact-root",
|
|
"/vol1/docker/kt-codex/artifacts/napcat-upstream-sync",
|
|
]);
|
|
assert.equal(upstreamCli.execute, true);
|
|
assert.equal(upstreamCli.useCodex, true);
|
|
assert.equal(
|
|
upstreamCli.artifactRoot,
|
|
"/vol1/docker/kt-codex/artifacts/napcat-upstream-sync",
|
|
);
|
|
|
|
for (const { expected, input } of [
|
|
{
|
|
expected: true,
|
|
input: { classification: "blocked", recommendedAction: "block" },
|
|
},
|
|
{
|
|
expected: false,
|
|
input: {
|
|
classification: "manual-review",
|
|
recommendedAction: "request-human-review",
|
|
},
|
|
},
|
|
{
|
|
expected: false,
|
|
input: {
|
|
classification: "safe-candidate",
|
|
recommendedAction: "create-candidate-branch",
|
|
},
|
|
},
|
|
]) {
|
|
assert.equal(shouldFailNapcatAutomationCli(input), expected);
|
|
}
|
|
|
|
const auxiliaryCliResults = [
|
|
parseNapcatSyncCandidateReviewCliArgs([
|
|
"node",
|
|
"server",
|
|
"--napcat-sync-candidate-review",
|
|
]),
|
|
parseNapcatRuntimeReleaseReadinessCliArgs([
|
|
"node",
|
|
"server",
|
|
"--napcat-runtime-release-readiness",
|
|
]),
|
|
parseNapcatRemoteDevHandoffCliArgs([
|
|
"node",
|
|
"server",
|
|
"--napcat-remote-dev-handoff",
|
|
]),
|
|
parseNasCodexBootstrapCliArgs(["node", "server", "--nas-codex-bootstrap"]),
|
|
];
|
|
assert.ok(auxiliaryCliResults.every((result) => result.execute === false));
|
|
assert.equal(
|
|
parseNapcatAutomationClassification("manual-review"),
|
|
"manual-review",
|
|
);
|
|
});
|
|
|
|
/** Verifies isolated skeleton artifacts and alignment with the shared default contract. */
|
|
test("aligns NapCat skeleton builders with defaults and rejects unsafe roots", () => {
|
|
const artifactBase = path.join(NAPCAT_TEST_ARTIFACT_ROOT, "defaults");
|
|
const unsafeRoot = path.resolve("/tmp/not-kt-skeleton");
|
|
const unsafeRootExisted = existsSync(unsafeRoot);
|
|
rmSync(artifactBase, { force: true, recursive: true });
|
|
|
|
try {
|
|
const candidate = buildNapcatSyncCandidateReview({
|
|
artifactRoot: path.join(artifactBase, "candidate"),
|
|
});
|
|
const runtime = buildNapcatRuntimeReleaseReadiness({
|
|
artifactRoot: path.join(artifactBase, "runtime"),
|
|
});
|
|
const remote = buildNapcatRemoteDevHandoff({
|
|
artifactRoot: path.join(artifactBase, "remote"),
|
|
});
|
|
const bootstrap = buildNasCodexBootstrapPlan({
|
|
artifactRoot: path.join(artifactBase, "bootstrap"),
|
|
execute: false,
|
|
});
|
|
|
|
assert.deepEqual(candidate.context, {
|
|
candidateBranch: napcatAutomationDefaults.candidateBranch,
|
|
forkBranch: napcatAutomationDefaults.forkBranch,
|
|
forkRepo: napcatAutomationDefaults.forkRepo,
|
|
lastAcceptedUpstreamBase: napcatAutomationDefaults.lastAcceptedUpstreamBase,
|
|
upstreamReleaseTag: napcatAutomationDefaults.upstreamReleaseRef,
|
|
upstreamRepo: napcatAutomationDefaults.upstreamRepo,
|
|
workspaceRoot: napcatAutomationDefaults.workspaceRoot,
|
|
});
|
|
assert.deepEqual(runtime.context, {
|
|
apiImageTag: napcatAutomationDefaults.apiImageTag,
|
|
candidateBranch: napcatAutomationDefaults.candidateBranch,
|
|
forkBranch: napcatAutomationDefaults.forkBranch,
|
|
forkRepo: napcatAutomationDefaults.forkRepo,
|
|
napcatImageTag: napcatAutomationDefaults.runtimeImageTag,
|
|
profile: napcatAutomationDefaults.runtimeProfile,
|
|
upstreamReleaseTag: napcatAutomationDefaults.upstreamReleaseRef,
|
|
workspaceRoot: napcatAutomationDefaults.workspaceRoot,
|
|
});
|
|
assert.deepEqual(remote.context, {
|
|
candidateBranch: napcatAutomationDefaults.candidateBranch,
|
|
forkBranch: napcatAutomationDefaults.forkBranch,
|
|
forkRepo: napcatAutomationDefaults.forkRepo,
|
|
targetHost: napcatAutomationDefaults.targetHost,
|
|
upstreamReleaseTag: napcatAutomationDefaults.upstreamReleaseRef,
|
|
workspaceRoot: napcatAutomationDefaults.workspaceRoot,
|
|
});
|
|
assert.equal(bootstrap.context.codexHome, napcatAutomationDefaults.codexHome);
|
|
assert.equal(
|
|
bootstrap.context.serviceName,
|
|
napcatAutomationDefaults.bootstrapServiceName,
|
|
);
|
|
assert.equal(
|
|
bootstrap.context.workspaceRoot,
|
|
napcatAutomationDefaults.workspaceRoot,
|
|
);
|
|
|
|
for (const artifact of [
|
|
...candidate.artifacts,
|
|
...runtime.artifacts,
|
|
...remote.artifacts,
|
|
]) {
|
|
assert.ok(existsSync(artifact.path), `missing skeleton artifact: ${artifact.path}`);
|
|
}
|
|
|
|
assert.throws(
|
|
() =>
|
|
buildNapcatRuntimeReleaseReadiness({
|
|
artifactRoot: "/tmp/not-kt-skeleton",
|
|
}),
|
|
/Unsafe NapCat Codex artifactRoot/,
|
|
);
|
|
if (!unsafeRootExisted) {
|
|
assert.equal(existsSync(unsafeRoot), false);
|
|
}
|
|
} finally {
|
|
rmSync(artifactBase, { force: true, recursive: true });
|
|
if (!unsafeRootExisted) {
|
|
rmSync(unsafeRoot, { force: true, recursive: true });
|
|
}
|
|
}
|
|
});
|
|
|
|
/** Verifies caller overrides replace, rather than supplement, shared NapCat defaults. */
|
|
test("propagates NapCat command overrides without stale default refs", () => {
|
|
const artifactBase = path.join(NAPCAT_TEST_ARTIFACT_ROOT, "overrides");
|
|
rmSync(artifactBase, { force: true, recursive: true });
|
|
|
|
try {
|
|
const candidateText = buildNapcatSyncCandidateReview({
|
|
artifactRoot: path.join(artifactBase, "candidate"),
|
|
candidateBranch: "custom-candidate",
|
|
lastAcceptedUpstreamBase: "custom-base",
|
|
}).commands.map((item) => item.command).join("\n");
|
|
assertIncludesAll(
|
|
candidateText,
|
|
["custom-candidate", "custom-base"],
|
|
"candidate override",
|
|
);
|
|
assertExcludesAll(
|
|
candidateText,
|
|
[
|
|
napcatAutomationDefaults.candidateBranch,
|
|
napcatAutomationDefaults.lastAcceptedUpstreamBase,
|
|
],
|
|
"candidate override",
|
|
);
|
|
|
|
const runtimeText = buildNapcatRuntimeReleaseReadiness({
|
|
artifactRoot: path.join(artifactBase, "runtime"),
|
|
napcatImageTag: "custom-image",
|
|
}).commands.map((item) => item.command).join("\n");
|
|
assert.ok(runtimeText.includes("custom-image"));
|
|
assert.ok(!runtimeText.includes(napcatAutomationDefaults.runtimeImageTag));
|
|
|
|
const auditText = buildNapcatUpstreamAudit({
|
|
dryMergeConflict: false,
|
|
forkBranch: "custom-fork",
|
|
forkPatchFiles: [],
|
|
lastAcceptedUpstreamBase: "custom-base",
|
|
upstreamChangedFiles: [],
|
|
upstreamReleaseTag: "custom-upstream",
|
|
}).commands.map((item) => item.command).join("\n");
|
|
assertIncludesAll(
|
|
auditText,
|
|
["custom-fork", "custom-base", "custom-upstream"],
|
|
"upstream audit override",
|
|
);
|
|
assertExcludesAll(
|
|
auditText,
|
|
[
|
|
napcatAutomationDefaults.forkBranch,
|
|
napcatAutomationDefaults.lastAcceptedUpstreamBase,
|
|
napcatAutomationDefaults.upstreamReleaseRef,
|
|
],
|
|
"upstream audit override",
|
|
);
|
|
} finally {
|
|
rmSync(artifactBase, { force: true, recursive: true });
|
|
}
|
|
});
|
|
|
|
/** Verifies dry-run and execute-mode NAS bootstrap command safety. */
|
|
test("keeps NAS Codex bootstrap bounded to trusted Linux preparation", () => {
|
|
const dryRun = buildNasCodexBootstrapPlan({ execute: false });
|
|
const dryRunText = dryRun.commands.map((item) => item.command).join("\n");
|
|
assertIncludesAll(
|
|
dryRunText,
|
|
[
|
|
"/vol1/docker/kt-codex/home/.codex",
|
|
"export NVM_DIR=/vol1/docker/kt-codex/runtime/nvm",
|
|
'test -s "$NVM_DIR/nvm.sh"',
|
|
"nvm --version",
|
|
"nvm current",
|
|
"node --version",
|
|
"corepack --version",
|
|
"pnpm --version",
|
|
"git --version",
|
|
"docker --version",
|
|
"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"));
|
|
|
|
const executePlan = buildNasCodexBootstrapPlan({ execute: true });
|
|
const executeText = executePlan.commands.map((item) => item.command).join("\n");
|
|
assert.equal(executePlan.execute, true);
|
|
assert.ok(executePlan.commands.some((command) => !command.readOnly));
|
|
assertIncludesAll(
|
|
executeText,
|
|
[
|
|
"install -d -m 700 /vol1/docker/kt-codex/home/.codex",
|
|
"install -d -m 755 /vol1/docker/kt-codex/runtime",
|
|
'git clone https://github.com/nvm-sh/nvm.git "$NVM_DIR"',
|
|
'git -C "$NVM_DIR" checkout "$NVM_TAG"',
|
|
'nvm install "$NODE_VERSION"',
|
|
'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",
|
|
);
|
|
assertExcludesAll(
|
|
executeText,
|
|
[
|
|
"git push",
|
|
"git merge",
|
|
"docker build",
|
|
"docker pull",
|
|
"kubectl",
|
|
"systemctl enable",
|
|
"systemctl start",
|
|
"cp ~/.codex",
|
|
"scp ~/.codex",
|
|
"C:\\Users",
|
|
"$remoteScript",
|
|
"tr -d",
|
|
"PowerShell",
|
|
"here-string",
|
|
],
|
|
"NAS bootstrap execute plan",
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
buildNasCodexBootstrapPlan({
|
|
codexHome: "C:\\Users\\example\\.codex",
|
|
execute: false,
|
|
}),
|
|
/Unsafe NAS Codex bootstrap path/,
|
|
);
|
|
});
|
|
|
|
/** Verifies package scripts and the actual MCP registration surface for NapCat tools. */
|
|
test("registers NapCat automation scripts and MCP tools", () => {
|
|
const packageJson = JSON.parse(
|
|
readWorkspaceText("mcp/ktWorkflow/package.json"),
|
|
) as { scripts?: Record<string, string> };
|
|
assert.ok(packageJson.scripts?.["napcat-remote-dev-handoff"]);
|
|
assert.ok(packageJson.scripts?.["change-doc-sync"]);
|
|
|
|
const registeredByFakeServer: string[] = [];
|
|
const fakeMcpServer = {
|
|
/**
|
|
* Records tool names supplied by registerTools without starting an MCP server.
|
|
* @param name - Registered MCP tool name.
|
|
* @returns Nothing; the name is appended to the local evidence list.
|
|
*/
|
|
registerTool(name: string): void {
|
|
registeredByFakeServer.push(name);
|
|
},
|
|
};
|
|
registerTools(fakeMcpServer as never);
|
|
|
|
for (const toolName of [
|
|
"kt_napcat_upstream_audit",
|
|
"kt_napcat_sync_candidate_review",
|
|
"kt_napcat_runtime_release_readiness",
|
|
"kt_napcat_remote_dev_handoff",
|
|
"kt_nas_codex_bootstrap_plan",
|
|
] as const) {
|
|
assert.ok(registeredToolNames.includes(toolName));
|
|
assert.ok(registeredByFakeServer.includes(toolName));
|
|
}
|
|
});
|
|
|
|
/** Verifies deterministic audit classification, read-only commands, and path validation. */
|
|
test("keeps NapCat upstream audit dry-run read-only and rejects unsafe metadata", () => {
|
|
const hotZoneAudit = classifyNapcatUpstreamAudit({
|
|
dryMergeConflict: false,
|
|
forkPatchFiles: ["packages/napcat-core/login/runtime.ts"],
|
|
upstreamChangedFiles: ["packages/napcat-core/login/runtime.ts"],
|
|
});
|
|
assert.equal(hotZoneAudit.classification, "manual-review");
|
|
assert.ok(hotZoneAudit.reasonCodes.includes("HOT_ZONE_CHANGED"));
|
|
|
|
const dryRunAudit = buildNapcatUpstreamAudit({
|
|
dryMergeConflict: false,
|
|
forkPatchFiles: ["packages/napcat-core/runtime/base.ts"],
|
|
upstreamChangedFiles: ["packages/napcat-core/runtime/upstream.ts"],
|
|
});
|
|
assert.equal(dryRunAudit.execute, false);
|
|
assert.ok(dryRunAudit.artifacts.length > 0);
|
|
assert.ok(dryRunAudit.commands.every((command) => command.readOnly));
|
|
|
|
const commandText = dryRunAudit.commands.map((item) => item.command).join("\n");
|
|
assertIncludesAll(
|
|
commandText,
|
|
[
|
|
"git status --short --branch",
|
|
"git fetch --dry-run upstream --tags --prune",
|
|
"git fetch --dry-run origin --prune",
|
|
"git diff --name-only",
|
|
"git merge-tree",
|
|
`FORK_BRANCH:-${napcatAutomationDefaults.forkBranch}`,
|
|
`UPSTREAM_RELEASE_REF:-${napcatAutomationDefaults.upstreamReleaseRef}`,
|
|
],
|
|
"NapCat upstream audit dry-run",
|
|
);
|
|
assert.ok(!commandText.includes("FORK_BRANCH:-HEAD"));
|
|
for (const command of dryRunAudit.commands) {
|
|
if (/^git\s+fetch(?:\s|$)/.test(command.command)) {
|
|
assert.match(command.command, /\s--dry-run(?:\s|$)/);
|
|
}
|
|
}
|
|
for (const { label, regex } of [
|
|
{ label: "git merge", regex: /(^|\n)\s*git\s+merge(?:\s|$)/ },
|
|
{ label: "git push", regex: /(^|\n)\s*git\s+push(?:\s|$)/ },
|
|
{ label: "docker build", regex: /(^|\n)\s*docker\s+build(?:\s|$)/ },
|
|
{ label: "kubectl", regex: /(^|\n)\s*kubectl(?:\s|$)/ },
|
|
{ label: "deploy", regex: /(^|\n)\s*deploy(?:\s|$)/ },
|
|
]) {
|
|
assert.equal(regex.test(commandText), false, `dry-run leaked ${label}`);
|
|
}
|
|
|
|
const invalidPathCases = [
|
|
{
|
|
forkPatchFiles: [],
|
|
name: "Windows absolute path",
|
|
upstreamChangedFiles: ["D:/repo/packages/napcat-core/login/runtime.ts"],
|
|
},
|
|
{
|
|
forkPatchFiles: [],
|
|
name: "Unix absolute path",
|
|
upstreamChangedFiles: ["/repo/packages/napcat-core/login/runtime.ts"],
|
|
},
|
|
{
|
|
forkPatchFiles: [],
|
|
name: "UNC path",
|
|
upstreamChangedFiles: [
|
|
"\\\\server\\share\\packages\\napcat-core\\login\\runtime.ts",
|
|
],
|
|
},
|
|
{
|
|
forkPatchFiles: [],
|
|
name: "Windows rooted path",
|
|
upstreamChangedFiles: ["\\packages\\napcat-core\\login\\runtime.ts"],
|
|
},
|
|
{
|
|
forkPatchFiles: [],
|
|
name: "Windows drive-relative path",
|
|
upstreamChangedFiles: ["C:packages/napcat-core/login/runtime.ts"],
|
|
},
|
|
{
|
|
forkPatchFiles: [],
|
|
name: "parent segment path",
|
|
upstreamChangedFiles: ["packages/../napcat-core/login/runtime.ts"],
|
|
},
|
|
{
|
|
forkPatchFiles: ["packages/../napcat-core/login/runtime.ts"],
|
|
name: "fork patch parent segment path",
|
|
upstreamChangedFiles: [],
|
|
},
|
|
];
|
|
for (const invalidPathCase of invalidPathCases) {
|
|
const result = classifyNapcatUpstreamAudit({
|
|
dryMergeConflict: false,
|
|
forkPatchFiles: invalidPathCase.forkPatchFiles,
|
|
upstreamChangedFiles: invalidPathCase.upstreamChangedFiles,
|
|
});
|
|
assert.equal(result.classification, "blocked", invalidPathCase.name);
|
|
assert.equal(result.recommendedAction, "block", invalidPathCase.name);
|
|
assert.ok(
|
|
result.reasonCodes.includes("METADATA_UNAVAILABLE"),
|
|
invalidPathCase.name,
|
|
);
|
|
}
|
|
|
|
for (const promptName of napcatAutomationPromptNames) {
|
|
assert.ok(loadNapcatAutomationPrompt(promptName).includes("Do not edit files"));
|
|
}
|
|
assert.throws(
|
|
() =>
|
|
loadNapcatAutomationPrompt(
|
|
"../../README" as NapcatAutomationPromptName,
|
|
),
|
|
/Unsupported NapCat automation prompt/,
|
|
);
|
|
});
|
|
|
|
/** Verifies the bounded Codex CLI command shape used by upstream audit automation. */
|
|
test("builds a stdin-safe, non-deploying NapCat Codex command", () => {
|
|
const command = buildNapcatCodexExecCommand({
|
|
artifactRoot: "/vol1/docker/kt-codex/artifacts/smoke",
|
|
contextPacketPath:
|
|
"/vol1/docker/kt-codex/artifacts/smoke/context-packet.md",
|
|
outputSchemaPath:
|
|
"/vol1/docker/kt-codex/workspace/KT/mcp/ktWorkflow/prompts/napcat/schemas/upstream-audit.schema.json",
|
|
promptName: "upstream-audit",
|
|
workspaceRoot: "/vol1/docker/kt-codex/workspace/KT",
|
|
});
|
|
assertIncludesAll(
|
|
command,
|
|
[
|
|
"--ask-for-approval never",
|
|
"codex --ask-for-approval never exec",
|
|
"CODEX_HOME='/vol1/docker/kt-codex/home/.codex'",
|
|
"--cd '/vol1/docker/kt-codex/workspace/KT'",
|
|
"--profile automation",
|
|
"--sandbox workspace-write",
|
|
"--json",
|
|
"--ephemeral",
|
|
"--output-schema '/vol1/docker/kt-codex/workspace/KT/mcp/ktWorkflow/prompts/napcat/schemas/upstream-audit.schema.json'",
|
|
"--output-last-message '/vol1/docker/kt-codex/artifacts/smoke/codex-upstream-audit.md'",
|
|
"- < '/vol1/docker/kt-codex/artifacts/smoke/context-packet.md'",
|
|
],
|
|
"NapCat Codex command",
|
|
);
|
|
assertExcludesAll(
|
|
command,
|
|
["codex exec", "$(cat", "git push", "kubectl"],
|
|
"NapCat Codex command",
|
|
);
|
|
});
|
|
|
|
/** Verifies Codex JSONL parsing, blocked fallbacks, artifact capture, and cleanup. */
|
|
test("captures NapCat Codex execution evidence and blocks invalid output", () => {
|
|
const artifactBase = path.join(NAPCAT_TEST_ARTIFACT_ROOT, "codex-execution");
|
|
const unsafeRoot = path.resolve("/tmp/not-kt");
|
|
const unsafeRootExisted = existsSync(unsafeRoot);
|
|
rmSync(artifactBase, { force: true, recursive: true });
|
|
|
|
try {
|
|
const invalidRoot = path.join(artifactBase, "invalid-schema");
|
|
const invalidAudit = buildNapcatUpstreamAudit({
|
|
artifactRoot: invalidRoot,
|
|
codexExecutor: () => ({
|
|
ok: true,
|
|
stderr: "schema warning",
|
|
stdout: '{"classification":"surprising"}\n',
|
|
}),
|
|
dryMergeConflict: false,
|
|
execute: true,
|
|
forkPatchFiles: [],
|
|
upstreamChangedFiles: [],
|
|
useCodex: true,
|
|
});
|
|
assert.equal(invalidAudit.classification, "blocked");
|
|
assert.ok(invalidAudit.reasonCodes.includes("CODEX_SCHEMA_INVALID"));
|
|
assert.ok(existsSync(path.join(invalidRoot, "codex-upstream-audit.jsonl")));
|
|
assert.ok(existsSync(path.join(invalidRoot, "context-packet.md")));
|
|
assert.equal(
|
|
readFileSync(
|
|
path.join(invalidRoot, "codex-upstream-audit.stderr.log"),
|
|
"utf8",
|
|
),
|
|
"schema warning",
|
|
);
|
|
|
|
const successAudit = buildNapcatUpstreamAudit({
|
|
artifactRoot: path.join(artifactBase, "item-text-success"),
|
|
codexExecutor: () => ({
|
|
ok: true,
|
|
stderr: "",
|
|
stdout: `${JSON.stringify({
|
|
item: {
|
|
text: JSON.stringify({
|
|
classification: "safe-candidate",
|
|
reasonCodes: ["NO_UPSTREAM_CHANGE"],
|
|
recommendedAction: "create-candidate-branch",
|
|
summary: "ok",
|
|
}),
|
|
type: "agent_message",
|
|
},
|
|
type: "item.completed",
|
|
})}\n`,
|
|
}),
|
|
dryMergeConflict: false,
|
|
execute: true,
|
|
forkPatchFiles: [],
|
|
upstreamChangedFiles: [],
|
|
useCodex: true,
|
|
});
|
|
assert.equal(successAudit.classification, "safe-candidate");
|
|
assert.ok(successAudit.reasonCodes.includes("NO_UPSTREAM_CHANGE"));
|
|
assert.equal(successAudit.recommendedAction, "create-candidate-branch");
|
|
|
|
const throwRoot = path.join(artifactBase, "executor-throw");
|
|
const throwAudit = buildNapcatUpstreamAudit({
|
|
artifactRoot: throwRoot,
|
|
codexExecutor: () => {
|
|
throw new Error("codex timed out");
|
|
},
|
|
dryMergeConflict: false,
|
|
execute: true,
|
|
forkPatchFiles: [],
|
|
upstreamChangedFiles: [],
|
|
useCodex: true,
|
|
});
|
|
assert.equal(throwAudit.classification, "blocked");
|
|
assert.match(
|
|
readFileSync(
|
|
path.join(throwRoot, "codex-upstream-audit.stderr.log"),
|
|
"utf8",
|
|
),
|
|
/codex timed out/,
|
|
);
|
|
|
|
let unsafeExecutorCalled = false;
|
|
assert.throws(
|
|
() =>
|
|
buildNapcatUpstreamAudit({
|
|
artifactRoot: "/tmp/not-kt",
|
|
codexExecutor: () => {
|
|
unsafeExecutorCalled = true;
|
|
return { ok: true, stderr: "", stdout: "{}\n" };
|
|
},
|
|
dryMergeConflict: false,
|
|
execute: true,
|
|
forkPatchFiles: [],
|
|
upstreamChangedFiles: [],
|
|
useCodex: true,
|
|
}),
|
|
/Unsafe NapCat Codex artifactRoot/,
|
|
);
|
|
assert.equal(unsafeExecutorCalled, false);
|
|
} finally {
|
|
rmSync(artifactBase, { force: true, recursive: true });
|
|
if (!unsafeRootExisted) {
|
|
rmSync(unsafeRoot, { force: true, recursive: true });
|
|
}
|
|
}
|
|
});
|
|
|
|
/** Verifies upstream-sync Jenkins safety and default dry-run staging. */
|
|
test("keeps the NapCat upstream Jenkins template non-mutating by default", () => {
|
|
const template = readWorkspaceText(
|
|
"mcp/ktWorkflow/ci/jenkins/KT-NapCatQQ-Upstream-Sync.Jenkinsfile",
|
|
);
|
|
assertIncludesAll(
|
|
template,
|
|
[
|
|
"RUN_CODEX_AUDIT",
|
|
"CONFIRM_CODEX_WORKSPACE_WRITE",
|
|
"RUN_CODEX_WORKSPACE_WRITE_AUDIT",
|
|
"buildDiscarder(logRotator",
|
|
"disableConcurrentBuilds()",
|
|
"archiveArtifacts artifacts: 'artifacts/napcat-upstream-sync/**'",
|
|
],
|
|
"NapCat upstream Jenkins template",
|
|
);
|
|
assertUpstreamDefaultAuditIsDryRun(template);
|
|
assertUsesNasWorkflowRunner(template, "NapCat upstream Jenkins template");
|
|
assertExcludesAll(
|
|
template,
|
|
["git merge", "git push", "docker build", "kubectl"],
|
|
"NapCat upstream Jenkins template",
|
|
);
|
|
|
|
const unsafeTemplate = [
|
|
"stage('ktWorkflow upstream audit dry-run') {",
|
|
' pnpm --dir "$KT_WORKFLOW_DIR" run napcat-upstream-audit -- \\',
|
|
" --execute \\",
|
|
" --use-codex",
|
|
"}",
|
|
"stage('ktWorkflow upstream audit with Codex') {",
|
|
' pnpm --dir "$KT_WORKFLOW_DIR" run napcat-upstream-audit -- \\',
|
|
" --execute \\",
|
|
" --use-codex",
|
|
"}",
|
|
].join("\n");
|
|
assert.throws(() => assertUpstreamDefaultAuditIsDryRun(unsafeTemplate));
|
|
});
|
|
|
|
/** Verifies runtime-release Jenkins approval boundaries and stage ordering. */
|
|
test("keeps runtime image build independent from explicit API promotion", () => {
|
|
const template = readWorkspaceText(
|
|
"mcp/ktWorkflow/ci/jenkins/KT-NapCatQQ-Runtime-Release.Jenkinsfile",
|
|
);
|
|
assertIncludesAll(
|
|
template,
|
|
[
|
|
"UPSTREAM_RELEASE_COMMIT",
|
|
"NAPCAT_BASE_IMAGE_DIGEST",
|
|
"choice(name: 'RUNTIME_BUILD_JOB'",
|
|
"choice(name: 'API_PROMOTION_JOB'",
|
|
"CONFIRM_API_PROMOTION_TARGET",
|
|
"PROMOTE_KT_TEMPLATE_API_MAIN",
|
|
"buildDiscarder(logRotator",
|
|
"disableConcurrentBuilds()",
|
|
"archiveArtifacts artifacts: 'artifacts/napcat-runtime-release/**'",
|
|
],
|
|
"NapCat runtime-release Jenkins template",
|
|
);
|
|
assertExcludesAll(
|
|
template,
|
|
[
|
|
"string(name: 'RUNTIME_BUILD_JOB'",
|
|
"string(name: 'API_PROMOTION_JOB'",
|
|
"git merge",
|
|
"git push",
|
|
"docker build",
|
|
"kubectl",
|
|
],
|
|
"NapCat runtime-release Jenkins template",
|
|
);
|
|
assertUsesNasWorkflowRunner(template, "NapCat runtime-release Jenkins template");
|
|
|
|
const runtimeStageStart = template.indexOf(
|
|
"stage('Runtime image build and publish')",
|
|
);
|
|
const promotionStageStart = template.indexOf("stage('API promotion')");
|
|
const pipelineEnd = template.indexOf("\n}\n\n/**", promotionStageStart);
|
|
assert.notEqual(runtimeStageStart, -1);
|
|
assert.notEqual(promotionStageStart, -1);
|
|
assert.notEqual(pipelineEnd, -1);
|
|
assert.ok(runtimeStageStart < promotionStageStart);
|
|
|
|
const runtimeStage = template.slice(runtimeStageStart, promotionStageStart);
|
|
const promotionStage = template.slice(promotionStageStart, pipelineEnd);
|
|
assert.ok(!runtimeStage.includes("CONFIRM_API_PROMOTION_TARGET"));
|
|
assertIncludesAll(
|
|
promotionStage,
|
|
[
|
|
"CONFIRM_API_PROMOTION_TARGET",
|
|
"PROMOTE_KT_TEMPLATE_API_MAIN",
|
|
"build job: params.API_PROMOTION_JOB",
|
|
],
|
|
"API promotion stage",
|
|
);
|
|
assert.ok(
|
|
promotionStage.indexOf("CONFIRM_API_PROMOTION_TARGET") <
|
|
promotionStage.indexOf("build job: params.API_PROMOTION_JOB"),
|
|
);
|
|
});
|
|
|
|
/** Verifies runtime-image Jenkins source, profile, Docker, and cleanup guardrails. */
|
|
test("keeps runtime-image Jenkins builds pinned and cleanup paths canonical", () => {
|
|
const template = readWorkspaceText(
|
|
"mcp/ktWorkflow/ci/jenkins/KT-NapCatQQ-Runtime-Image-Build.Jenkinsfile",
|
|
);
|
|
assertUsesNasWorkflowRunner(template, "NapCat runtime-image Jenkins template");
|
|
assertIncludesAll(
|
|
template,
|
|
[
|
|
'ARTIFACTS_BASE_REAL=$(realpath -m /vol1/docker/kt-codex/artifacts)',
|
|
'ARTIFACT_ROOT_REAL=$(realpath -m "$KT_ARTIFACT_ROOT")',
|
|
'case "$ARTIFACT_ROOT_REAL" in',
|
|
'"$ARTIFACTS_BASE_REAL"/*) ;;',
|
|
'ARTIFACT_DIR="$ARTIFACT_ROOT_REAL/runtime-image-build"',
|
|
'BUILD_WORKDIR="$ARTIFACT_ROOT_REAL/runtime-image-build-workspace/$BUILD_ID_SAFE"',
|
|
"NAPCAT_FORK_REF",
|
|
"NAPCAT_GIT_REPOSITORY = '/vol1/docker/kt-codex/git/NapCatQQ.git'",
|
|
"QQBOT_NAPCAT_IMAGE_OVERRIDE",
|
|
"QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE",
|
|
"NAPCAT_BASE_IMAGE_DIGEST",
|
|
"requireRuntimeImageParameters(",
|
|
"requireImageProfileMatch(",
|
|
"-v /var/run/docker.sock:/var/run/docker.sock",
|
|
"--user 0:0",
|
|
"API_GIT_REFERENCE_HOST",
|
|
"API_GIT_REFERENCE",
|
|
'-v "$API_GIT_REFERENCE_HOST":"$API_GIT_REFERENCE":ro',
|
|
"--cap-add SYS_ADMIN",
|
|
"--security-opt apparmor=unconfined",
|
|
"--security-opt seccomp=unconfined",
|
|
"-e NAPCAT_REQUIRE_DEVICE_PROFILE=1",
|
|
'git -C "$API_ROOT" fetch "$API_GIT_REFERENCE" refs/remotes/origin/main',
|
|
'git -C "$API_ROOT" merge --ff-only FETCH_HEAD',
|
|
'"$ARTIFACT_DIR/api-root-commit.txt"',
|
|
'git clone "$NAPCAT_GIT_REPOSITORY" "$NAPCAT_ROOT"',
|
|
'BUILD_WORKDIR="$ARTIFACT_ROOT_REAL/runtime-image-build-workspace/$BUILD_ID_SAFE"',
|
|
"KT_ARTIFACT_ROOT must stay under /vol1/docker/kt-codex/artifacts",
|
|
"scripts/napcat-desktop-cn-stage-build.mjs",
|
|
'--napcat-root "$NAPCAT_ROOT"',
|
|
'--napcat-base-image-digest "$NAPCAT_BASE_IMAGE_DIGEST"',
|
|
"docker build",
|
|
'docker exec "$VERIFY_NAME" sh /ci/napcat-desktop-cn/verify.sh',
|
|
"archiveArtifacts artifacts: 'artifacts/napcat-runtime-image-build/**'",
|
|
],
|
|
"NapCat runtime-image Jenkins template",
|
|
);
|
|
assert.ok(!template.includes('case "$KT_ARTIFACT_ROOT" in'));
|
|
assertExcludesAll(
|
|
template,
|
|
[
|
|
"PROMOTE_API_RUNTIME",
|
|
"CONFIRM_API_PROMOTION_TARGET",
|
|
"kubectl",
|
|
"git push",
|
|
"git merge",
|
|
"/vol1/docker/kt-codex/workspace/KT/GitHub/NapCatQQ",
|
|
],
|
|
"NapCat runtime-image Jenkins template",
|
|
);
|
|
});
|