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