diff --git a/src/selfTest.ts b/src/selfTest.ts index 7ad3b75..33ef5dd 100644 --- a/src/selfTest.ts +++ b/src/selfTest.ts @@ -1,4 +1,4 @@ -import { mkdirSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; import path from "node:path"; import { @@ -41,6 +41,7 @@ import { upstreamAuditOutputSchema, } from "./tools/napcatAutomation.types.js"; import { + buildNapcatCodexExecCommand, buildNapcatUpstreamAudit, classifyNapcatUpstreamAudit, } from "./tools/napcatAutomation.js"; @@ -119,6 +120,66 @@ export async function runSelfTest(): Promise { "NapCat upstream audit dry-run artifact plan self-check failed", ); } + const codexCommand = 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", + }); + if (!codexCommand.includes("--ask-for-approval never")) { + throw new Error("NapCat Codex runner approval policy self-check failed"); + } + for (const expected of [ + "CODEX_HOME='/vol1/docker/kt-codex/home/.codex'", + "--cd '/vol1/docker/kt-codex/workspace/KT'", + "--profile-v2 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'", + "\"$(cat '/vol1/docker/kt-codex/artifacts/smoke/context-packet.md')\"", + ]) { + if (!codexCommand.includes(expected)) { + throw new Error(`NapCat Codex runner command missing: ${expected}`); + } + } + if (codexCommand.includes("git push") || codexCommand.includes("kubectl")) { + throw new Error("NapCat Codex runner mutation guard self-check failed"); + } + const codexExecutionArtifactRoot = resolveInsideRoot( + ".kt-workspace/test-artifacts/napcat-codex-runner-self-test", + ); + const codexInvalidSchemaAudit = buildNapcatUpstreamAudit({ + artifactRoot: codexExecutionArtifactRoot, + codexExecutor: () => ({ + ok: true, + stderr: "", + stdout: "{\"classification\":\"surprising\"}\n", + }), + dryMergeConflict: false, + execute: true, + forkPatchFiles: [], + upstreamChangedFiles: [], + useCodex: true, + }); + if ( + codexInvalidSchemaAudit.classification !== "blocked" || + !codexInvalidSchemaAudit.reasonCodes.includes("CODEX_SCHEMA_INVALID") + ) { + throw new Error("NapCat Codex invalid schema fallback self-check failed"); + } + if ( + !existsSync( + path.join(codexExecutionArtifactRoot, "codex-upstream-audit.jsonl"), + ) || + !existsSync(path.join(codexExecutionArtifactRoot, "context-packet.md")) + ) { + throw new Error("NapCat Codex artifact capture self-check failed"); + } const dryRunAuditCommands: string[] = []; for (const command of dryRunAudit.commands) { if (!command.readOnly) { diff --git a/src/tools/napcatAutomation.ts b/src/tools/napcatAutomation.ts index 337ee25..0b884e9 100644 --- a/src/tools/napcatAutomation.ts +++ b/src/tools/napcatAutomation.ts @@ -1,8 +1,18 @@ +import { execSync } from 'node:child_process'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; + +import { upstreamAuditOutputSchema } from './napcatAutomation.types.js'; +import { loadNapcatAutomationPrompt } from './napcatAutomation.prompts.js'; import type { NapcatAuditClassificationInput, NapcatAuditClassificationResult, NapcatAuditCommand, NapcatAutomationReasonCode, + NapcatCodexExecCommandInput, + NapcatCodexExecResult, + NapcatCodexExecutor, + NapcatUpstreamAuditInput, NapcatUpstreamAuditResult, } from './napcatAutomation.types.js'; @@ -75,6 +85,330 @@ const hotZoneMatchers: Array<{ pattern: string; regex: RegExp }> = [ { pattern: 'vite*.ts', regex: /(?:^|\/)vite[^/]*\.ts$/i }, ]; +/** + * Quotes a POSIX shell path argument for NAS-side Codex automation commands. + * @param value - Absolute or repo-relative filesystem path that will be interpreted by a Linux shell. + * @returns A single-quoted shell token with embedded single quotes escaped. + */ +function shellQuotePath(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +/** + * Derives the default trusted Codex home from the NAS workspace root layout. + * @param workspaceRoot - Linux workspace root passed to `codex exec --cd`, usually `/vol1/docker/kt-codex/workspace/KT`. + * @returns The colocated Codex home path, or the stabilized NAS default when the workspace root does not match the expected layout. + */ +function deriveCodexHome(workspaceRoot: string): string { + const marker = '/workspace/'; + const markerIndex = workspaceRoot.indexOf(marker); + if (markerIndex >= 0) { + return path.posix.join(workspaceRoot.slice(0, markerIndex), 'home/.codex'); + } + return '/vol1/docker/kt-codex/home/.codex'; +} + +/** + * Builds the bounded Codex CLI command used by NapCat upstream audit automation. + * @param input - Linux paths for artifact capture, the context packet prompt, JSON schema validation, and the trusted KT workspace root; `codexHome` may override the derived NAS default. + * @returns A POSIX shell command that runs `codex exec` with workspace-write sandboxing, never approval, JSONL output, and last-message artifact capture. + */ +export function buildNapcatCodexExecCommand( + input: NapcatCodexExecCommandInput, +): string { + const outputLastMessagePath = path.posix.join( + input.artifactRoot, + `codex-${input.promptName}.md`, + ); + const codexHome = input.codexHome ?? deriveCodexHome(input.workspaceRoot); + + return [ + `CODEX_HOME=${shellQuotePath(codexHome)}`, + 'codex exec', + `--cd ${shellQuotePath(input.workspaceRoot)}`, + '--profile-v2 automation', + '--sandbox workspace-write', + '--ask-for-approval never', + '--json', + '--ephemeral', + `--output-schema ${shellQuotePath(input.outputSchemaPath)}`, + `--output-last-message ${shellQuotePath(outputLastMessagePath)}`, + `"$(cat ${shellQuotePath(input.contextPacketPath)})"`, + ].join(' '); +} + +/** + * Builds a POSIX-style artifact child path while preserving deterministic command text for NAS execution. + * @param artifactRoot - Artifact directory supplied by automation input or the default KT test-artifact root. + * @param fileName - File name to place directly under the artifact root. + * @returns A stable path string that can be used by both local self-tests and Linux-side command construction. + */ +function joinNapcatArtifactPath(artifactRoot: string, fileName: string): string { + return path.posix.join(artifactRoot, fileName); +} + +/** + * Resolves the artifact root for dry-run and execute-mode NapCat upstream audit outputs. + * @param artifactRoot - Optional caller-provided artifact directory; when omitted the standard KT test-artifact folder is used. + * @returns A non-empty artifact root path. + */ +function resolveNapcatArtifactRoot(artifactRoot?: string): string { + return ( + artifactRoot?.trim() || + '.kt-workspace/test-artifacts/napcat-upstream-audit' + ); +} + +/** + * Resolves the Linux workspace root used by the NAS Codex automation runner. + * @param workspaceRoot - Optional trusted KT workspace root override. + * @returns The configured workspace root or the stabilized NAS kt-codex workspace path. + */ +function resolveNapcatCodexWorkspaceRoot(workspaceRoot?: string): string { + return workspaceRoot?.trim() || '/vol1/docker/kt-codex/workspace/KT'; +} + +/** + * Resolves the JSON schema path passed to `codex exec --output-schema`. + * @param input - Upstream audit input containing an optional schema path and workspace root. + * @returns The caller-provided schema path or the source-controlled upstream-audit schema under the trusted workspace root. + */ +function resolveNapcatCodexOutputSchemaPath( + input: NapcatUpstreamAuditInput, +): string { + if (input.outputSchemaPath?.trim()) { + return input.outputSchemaPath.trim(); + } + return path.posix.join( + resolveNapcatCodexWorkspaceRoot(input.workspaceRoot), + 'mcp/ktWorkflow/prompts/napcat/schemas/upstream-audit.schema.json', + ); +} + +/** + * Builds a deterministic Markdown context packet for Codex upstream audit reasoning. + * @param input - NapCat upstream audit metadata and optional repo/ref hints; values are serialized as context only and must not be executed. + * @param classification - Deterministic local classifier output that Codex can compare against upstream risk evidence. + * @returns Markdown prompt content with the source-controlled prompt, sanitized metadata, and explicit no-mutation constraints. + */ +function buildNapcatCodexContextPacket( + input: NapcatUpstreamAuditInput, + classification: NapcatAuditClassificationResult, +): string { + const promptText = loadNapcatAutomationPrompt('upstream-audit').trim(); + const context = { + classification, + constraints: { + forbiddenCommands: ['git push', 'kubectl', 'docker build', 'deploy'], + outputSchema: 'upstream-audit.schema.json', + }, + input: { + createCandidateBranch: input.createCandidateBranch === true, + dryMergeConflict: input.dryMergeConflict, + forkBranch: input.forkBranch, + forkPatchFiles: input.forkPatchFiles, + forkRepo: redactUrlCredentials(input.forkRepo), + lastAcceptedUpstreamBase: input.lastAcceptedUpstreamBase, + upstreamChangedFiles: input.upstreamChangedFiles, + upstreamReleaseTag: input.upstreamReleaseTag, + upstreamRepo: redactUrlCredentials(input.upstreamRepo), + }, + schemaVersion: 1, + }; + + return [ + '# NapCat Upstream Audit Codex Context', + '', + '## Source Prompt', + '', + promptText, + '', + '## Context', + '', + '```json', + JSON.stringify(context, null, 2), + '```', + '', + '## Safety', + '', + '- Do not edit files.', + '- Do not run git push, kubectl, docker build, deploy, or release mutation commands.', + '- Return only JSON that matches the supplied output schema.', + '', + ].join('\n'); +} + +/** + * Redacts credentials from URL-shaped repository hints before they enter context artifacts. + * @param value - Optional repo URL or short repository identifier from automation input. + * @returns The original value when no URL credentials are present; otherwise the same URL with userinfo removed. + */ +function redactUrlCredentials(value?: string): string | undefined { + if (!value) return undefined; + return value.replace(/:\/\/[^/@\s]+@/g, '://'); +} + +/** + * Runs the Codex CLI command with the task-required ten minute timeout. + * @param command - POSIX shell command produced by `buildNapcatCodexExecCommand`. + * @returns Captured stdout/stderr and an ok flag; failures are returned so callers can preserve artifacts and classify as blocked. + */ +function executeNapcatCodexCommand(command: string): NapcatCodexExecResult { + try { + const stdout = execSync(command, { + encoding: 'utf8', + maxBuffer: 10 * 1024 * 1024, + shell: process.platform === 'win32' ? undefined : '/bin/bash', + timeout: 10 * 60 * 1000, + }); + return { ok: true, stderr: '', stdout }; + } catch (error) { + const execError = error as { + stderr?: Buffer | string; + stdout?: Buffer | string; + }; + return { + ok: false, + stderr: bufferLikeToString(execError.stderr), + stdout: bufferLikeToString(execError.stdout), + }; + } +} + +/** + * Converts optional exec output into UTF-8 text for artifact preservation. + * @param value - Buffer or string captured from a child-process result. + * @returns String output, or an empty string when the stream was not captured. + */ +function bufferLikeToString(value?: Buffer | string): string { + if (!value) return ''; + return Buffer.isBuffer(value) ? value.toString('utf8') : value; +} + +/** + * Parses Codex stdout and last-message content into the strict upstream audit schema. + * @param stdout - JSONL stdout emitted by `codex exec --json`; the final schema payload may be a raw line or nested text field. + * @param lastMessage - Optional content from `--output-last-message` when Codex wrote one. + * @returns Parsed classification result, or null when no schema-valid payload can be found. + */ +function parseNapcatCodexAuditOutput( + stdout: string, + lastMessage: string, +): NapcatAuditClassificationResult | null { + const candidates = [ + ...stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .reverse(), + lastMessage, + stdout, + ]; + + for (const candidate of candidates) { + const parsed = parseNapcatCodexAuditCandidate(candidate); + if (parsed) return parsed; + } + + return null; +} + +/** + * Attempts to parse one raw Codex output candidate, including nested string payloads. + * @param candidate - Raw JSON, JSONL line, markdown fenced JSON, or nested Codex event text. + * @returns A schema-valid upstream audit result, or null when the candidate does not contain one. + */ +function parseNapcatCodexAuditCandidate( + candidate: string, +): NapcatAuditClassificationResult | null { + const trimmed = candidate.trim(); + if (!trimmed) return null; + + for (const jsonText of collectJsonTextCandidates(trimmed)) { + try { + const value = JSON.parse(jsonText) as unknown; + const direct = upstreamAuditOutputSchema.safeParse(value); + if (direct.success) return direct.data; + + const nested = parseNestedCodexAuditCandidate(value); + if (nested) return nested; + } catch { + continue; + } + } + + return null; +} + +/** + * Extracts direct and fenced JSON snippets from Codex text output. + * @param value - Raw Codex event text or last-message Markdown. + * @returns Candidate JSON strings in most-specific-first order. + */ +function collectJsonTextCandidates(value: string): string[] { + const candidates = [value]; + const fencedJson = value.match(/```(?:json)?\s*([\s\S]*?)```/i)?.[1]?.trim(); + if (fencedJson) { + candidates.unshift(fencedJson); + } + return candidates; +} + +/** + * Searches common Codex JSONL event shapes for a nested schema payload string. + * @param value - Parsed JSONL event or raw nested JSON value from Codex stdout. + * @returns A schema-valid upstream audit result, or null when no nested payload matches. + */ +function parseNestedCodexAuditCandidate( + value: unknown, +): NapcatAuditClassificationResult | null { + if (!value || typeof value !== 'object') return null; + if (Array.isArray(value)) { + for (const item of [...value].reverse()) { + const parsed = parseNestedCodexAuditCandidate(item); + if (parsed) return parsed; + } + return null; + } + + const record = value as Record; + for (const key of ['content', 'message', 'output', 'text', 'lastMessage']) { + const nestedValue = record[key]; + if (typeof nestedValue === 'string') { + const parsed = parseNapcatCodexAuditCandidate(nestedValue); + if (parsed) return parsed; + } else if (nestedValue && typeof nestedValue === 'object') { + const parsed = parseNestedCodexAuditCandidate(nestedValue); + if (parsed) return parsed; + } + } + + return null; +} + +/** + * Builds the blocked result used when Codex execution or schema parsing fails. + * @param artifacts - Artifact paths already written or reserved for the failed execution. + * @param commands - Command metadata returned to callers for auditability. + * @returns A blocked upstream audit result with the `CODEX_SCHEMA_INVALID` reason code. + */ +function buildCodexSchemaInvalidResult( + artifacts: NapcatUpstreamAuditResult['artifacts'], + commands: NapcatAuditCommand[], +): NapcatUpstreamAuditResult { + return { + artifacts, + classification: 'blocked', + commands, + execute: true, + hotZonePatterns: [...napcatUpstreamHotZonePatterns], + reasonCodes: ['CODEX_SCHEMA_INVALID'], + recommendedAction: 'block', + summary: + 'Codex upstream audit execution did not produce schema-valid JSON; inspect context, JSONL stdout, and last-message artifacts before continuing.', + }; +} + /** * Checks whether Git audit metadata contains a repo-relative path that the classifier can trust. * @param value - Raw path string from fork/upstream Git diff collectors; empty entries are allowed because collectors may emit blank lines. @@ -289,14 +623,15 @@ export function classifyNapcatUpstreamAudit( } /** - * Builds a read-only upstream audit plan for NapCatQQ release sync automation. - * @param input - Same deterministic classifier input supplied by the dry-run collector; values are embedded only in the returned classification, never executed. - * @returns Dry-run commands and artifact paths for a future collector, with no Git merge/push, Docker, Kubernetes, or release mutations. + * Builds or executes a NapCatQQ upstream audit plan. + * @param input - Deterministic collector metadata plus optional Codex automation settings; execute mode writes context/stdout artifacts and runs the bounded Codex command only when both `execute` and `useCodex` are true. + * @returns Dry-run classification and commands, or Codex schema output with artifact paths; failures are blocked with `CODEX_SCHEMA_INVALID` and preserved artifacts. */ export function buildNapcatUpstreamAudit( - input: NapcatAuditClassificationInput, + input: NapcatUpstreamAuditInput, ): NapcatUpstreamAuditResult { const classification = classifyNapcatUpstreamAudit(input); + const artifactRoot = resolveNapcatArtifactRoot(input.artifactRoot); const commands: NapcatAuditCommand[] = [ { command: 'git status --short --branch', @@ -335,19 +670,99 @@ export function buildNapcatUpstreamAudit( }, ]; + if (input.execute === true && input.useCodex === true) { + const promptName = input.promptName ?? 'upstream-audit'; + const contextPacketPath = joinNapcatArtifactPath( + artifactRoot, + 'context-packet.md', + ); + const jsonlPath = joinNapcatArtifactPath( + artifactRoot, + 'codex-upstream-audit.jsonl', + ); + const lastMessagePath = joinNapcatArtifactPath( + artifactRoot, + `codex-${promptName}.md`, + ); + const outputSchemaPath = resolveNapcatCodexOutputSchemaPath(input); + const workspaceRoot = resolveNapcatCodexWorkspaceRoot(input.workspaceRoot); + const codexCommand = buildNapcatCodexExecCommand({ + artifactRoot, + codexHome: input.codexHome, + contextPacketPath, + outputSchemaPath, + promptName, + workspaceRoot, + }); + const codexArtifacts: NapcatUpstreamAuditResult['artifacts'] = [ + { path: contextPacketPath, type: 'context' }, + { path: jsonlPath, type: 'jsonl' }, + { path: lastMessagePath, type: 'markdown' }, + ]; + const codexCommands: NapcatAuditCommand[] = [ + { + command: codexCommand, + description: + 'Run Codex CLI upstream audit with bounded sandboxing and capture JSONL/last-message artifacts.', + readOnly: false, + }, + ]; + + mkdirSync(artifactRoot, { recursive: true }); + writeFileSync( + contextPacketPath, + buildNapcatCodexContextPacket(input, classification), + 'utf8', + ); + writeFileSync(lastMessagePath, '', 'utf8'); + + let codexResult: NapcatCodexExecResult; + try { + const executor: NapcatCodexExecutor = + input.codexExecutor ?? executeNapcatCodexCommand; + codexResult = executor(codexCommand); + } catch (error) { + codexResult = { + ok: false, + stderr: String(error), + stdout: '', + }; + } + + writeFileSync(jsonlPath, codexResult.stdout, 'utf8'); + const lastMessage = existsSync(lastMessagePath) + ? readFileSync(lastMessagePath, 'utf8') + : ''; + const parsed = codexResult.ok + ? parseNapcatCodexAuditOutput(codexResult.stdout, lastMessage) + : null; + + if (!parsed) { + return buildCodexSchemaInvalidResult(codexArtifacts, codexCommands); + } + + return { + ...parsed, + artifacts: codexArtifacts, + commands: codexCommands, + execute: true, + hotZonePatterns: [...napcatUpstreamHotZonePatterns], + }; + } + return { ...classification, artifacts: [ { - path: '.kt-workspace/test-artifacts/napcat-upstream-audit/context.md', + path: joinNapcatArtifactPath(artifactRoot, 'context.md'), type: 'markdown', }, { - path: '.kt-workspace/test-artifacts/napcat-upstream-audit/classification.json', + path: joinNapcatArtifactPath(artifactRoot, 'classification.json'), type: 'json', }, { - path: '.kt-workspace/test-artifacts/napcat-upstream-audit/files.json', + path: joinNapcatArtifactPath(artifactRoot, 'files.json'), type: 'json', }, ], diff --git a/src/tools/napcatAutomation.types.ts b/src/tools/napcatAutomation.types.ts index 815ff60..01a1a9f 100644 --- a/src/tools/napcatAutomation.types.ts +++ b/src/tools/napcatAutomation.types.ts @@ -56,16 +56,41 @@ export interface NapcatAuditCommand { readOnly: boolean; } -export interface NapcatUpstreamAuditInput { +export interface NapcatCodexExecCommandInput { + artifactRoot: string; + codexHome?: string; + contextPacketPath: string; + outputSchemaPath: string; + promptName: 'upstream-audit'; + workspaceRoot: string; +} + +export interface NapcatCodexExecResult { + ok: boolean; + stderr: string; + stdout: string; +} + +export type NapcatCodexExecutor = ( + command: string, +) => NapcatCodexExecResult; + +export interface NapcatUpstreamAuditInput + extends NapcatAuditClassificationInput { artifactRoot?: string; + codexExecutor?: NapcatCodexExecutor; + codexHome?: string; createCandidateBranch?: boolean; execute?: boolean; forkBranch?: string; forkRepo?: string; lastAcceptedUpstreamBase?: string; + outputSchemaPath?: string; + promptName?: 'upstream-audit'; upstreamReleaseTag?: string; upstreamRepo?: string; useCodex?: boolean; + workspaceRoot?: string; } export interface NapcatAutomationArtifact { @@ -85,7 +110,7 @@ export interface NapcatUpstreamAuditResult extends NapcatAuditClassificationResult { artifacts: NapcatAutomationArtifact[]; commands: NapcatAuditCommand[]; - execute: false; + execute: boolean; hotZonePatterns: string[]; }