patchwarden 0.4.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PatchWarden.cmd +51 -0
- package/README.en.md +1022 -0
- package/README.md +897 -358
- package/dist/assessments/agentAssessor.d.ts +15 -0
- package/dist/assessments/agentAssessor.js +293 -0
- package/dist/assessments/assessmentStore.d.ts +133 -0
- package/dist/assessments/assessmentStore.js +238 -0
- package/dist/assessments/confirmCli.d.ts +9 -0
- package/dist/assessments/confirmCli.js +26 -0
- package/dist/config.d.ts +22 -1
- package/dist/config.js +156 -3
- package/dist/direct/directAudit.d.ts +23 -0
- package/dist/direct/directAudit.js +309 -0
- package/dist/direct/directGuards.d.ts +20 -0
- package/dist/direct/directGuards.js +137 -0
- package/dist/direct/directPatch.d.ts +17 -0
- package/dist/direct/directPatch.js +113 -0
- package/dist/direct/directSessionStore.d.ts +63 -0
- package/dist/direct/directSessionStore.js +192 -0
- package/dist/direct/directVerification.d.ts +12 -0
- package/dist/direct/directVerification.js +96 -0
- package/dist/doctor.js +73 -5
- package/dist/logging.d.ts +52 -0
- package/dist/logging.js +123 -0
- package/dist/runner/agentInvocation.d.ts +23 -0
- package/dist/runner/agentInvocation.js +106 -0
- package/dist/runner/changeCapture.d.ts +70 -0
- package/dist/runner/changeCapture.js +278 -6
- package/dist/runner/runTask.js +271 -65
- package/dist/runner/simpleProcess.d.ts +21 -0
- package/dist/runner/simpleProcess.js +206 -0
- package/dist/runner/watch.js +17 -2
- package/dist/security/commandGuard.d.ts +2 -1
- package/dist/security/commandGuard.js +25 -3
- package/dist/security/riskEngine.d.ts +27 -0
- package/dist/security/riskEngine.js +118 -0
- package/dist/security/runtimeGuard.d.ts +6 -0
- package/dist/security/runtimeGuard.js +28 -0
- package/dist/smoke-test.js +1353 -7
- package/dist/test/unit/android-doctor.test.d.ts +1 -0
- package/dist/test/unit/android-doctor.test.js +118 -0
- package/dist/test/unit/chinese-path.test.d.ts +1 -0
- package/dist/test/unit/chinese-path.test.js +91 -0
- package/dist/test/unit/command-guard.test.d.ts +1 -0
- package/dist/test/unit/command-guard.test.js +160 -0
- package/dist/test/unit/direct-guards.test.d.ts +1 -0
- package/dist/test/unit/direct-guards.test.js +213 -0
- package/dist/test/unit/logging.test.d.ts +1 -0
- package/dist/test/unit/logging.test.js +275 -0
- package/dist/test/unit/path-guard.test.d.ts +1 -0
- package/dist/test/unit/path-guard.test.js +109 -0
- package/dist/test/unit/safe-status.test.d.ts +1 -0
- package/dist/test/unit/safe-status.test.js +165 -0
- package/dist/test/unit/sensitive-guard.test.d.ts +1 -0
- package/dist/test/unit/sensitive-guard.test.js +104 -0
- package/dist/test/unit/sync-file.test.d.ts +1 -0
- package/dist/test/unit/sync-file.test.js +154 -0
- package/dist/test/unit/watcher-status.test.d.ts +1 -0
- package/dist/test/unit/watcher-status.test.js +169 -0
- package/dist/tools/androidDoctor.d.ts +38 -0
- package/dist/tools/androidDoctor.js +391 -0
- package/dist/tools/applyPatch.d.ts +16 -0
- package/dist/tools/applyPatch.js +41 -0
- package/dist/tools/auditSession.d.ts +5 -0
- package/dist/tools/auditSession.js +12 -0
- package/dist/tools/auditTask.d.ts +7 -0
- package/dist/tools/auditTask.js +105 -7
- package/dist/tools/createDirectSession.d.ts +14 -0
- package/dist/tools/createDirectSession.js +54 -0
- package/dist/tools/createTask.d.ts +48 -1
- package/dist/tools/createTask.js +298 -47
- package/dist/tools/finalizeDirectSession.d.ts +19 -0
- package/dist/tools/finalizeDirectSession.js +84 -0
- package/dist/tools/getTaskSummary.d.ts +50 -0
- package/dist/tools/getTaskSummary.js +79 -2
- package/dist/tools/healthCheck.d.ts +27 -12
- package/dist/tools/healthCheck.js +44 -6
- package/dist/tools/readWorkspaceFile.d.ts +7 -1
- package/dist/tools/readWorkspaceFile.js +48 -2
- package/dist/tools/registry.js +270 -19
- package/dist/tools/runVerification.d.ts +16 -0
- package/dist/tools/runVerification.js +32 -0
- package/dist/tools/safeStatus.d.ts +19 -0
- package/dist/tools/safeStatus.js +72 -0
- package/dist/tools/savePlan.d.ts +1 -0
- package/dist/tools/savePlan.js +38 -7
- package/dist/tools/searchWorkspace.d.ts +19 -0
- package/dist/tools/searchWorkspace.js +205 -0
- package/dist/tools/syncFile.d.ts +18 -0
- package/dist/tools/syncFile.js +65 -0
- package/dist/tools/taskOutputs.d.ts +2 -2
- package/dist/tools/taskTemplates.js +2 -1
- package/dist/tools/toolCatalog.d.ts +4 -3
- package/dist/tools/toolCatalog.js +34 -5
- package/dist/tools/waitForTask.d.ts +2 -2
- package/dist/tools/waitForTask.js +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +2 -2
- package/dist/watcherStatus.d.ts +1 -0
- package/dist/watcherStatus.js +96 -4
- package/docs/chatgpt-usage.md +101 -0
- package/docs/performance-notes.md +55 -0
- package/docs/release-checklist.md +14 -0
- package/docs/release-v0.6.0.md +71 -0
- package/docs/release-v0.6.1.md +75 -0
- package/examples/config.example.json +5 -0
- package/examples/openai-tunnel/README.md +11 -4
- package/examples/openai-tunnel/chatgpt-test-prompt.md +18 -14
- package/examples/openai-tunnel/tunnel-client.example.yaml +8 -0
- package/package.json +13 -8
- package/scripts/brand-check.js +58 -12
- package/scripts/control-smoke.js +206 -0
- package/scripts/http-mcp-smoke.js +10 -2
- package/scripts/launchers/Check-PatchWarden-Health.cmd +6 -0
- package/scripts/launchers/Reset-PatchWarden-Tunnel-Key.cmd +6 -0
- package/scripts/launchers/Restart-PatchWarden.cmd +6 -0
- package/scripts/launchers/Start-PatchWarden-Direct-Tunnel.cmd +7 -0
- package/scripts/launchers/Start-PatchWarden-Tunnel.cmd +7 -0
- package/scripts/lifecycle-smoke.js +402 -8
- package/scripts/manage-patchwarden.ps1 +639 -0
- package/scripts/mcp-manifest-check.js +123 -58
- package/scripts/mcp-smoke.js +160 -2
- package/scripts/pack-clean.js +160 -5
- package/scripts/package-manifest-check.js +78 -0
- package/scripts/patchwarden-mcp-direct.cmd +7 -0
- package/scripts/patchwarden-mcp-stdio.cmd +1 -1
- package/scripts/restart-patchwarden.ps1 +5 -4
- package/scripts/start-patchwarden-tunnel.ps1 +261 -30
- package/scripts/tunnel-supervisor-smoke.js +36 -4
- package/scripts/unit-tests.js +36 -0
- package/scripts/watcher-supervisor-smoke.js +10 -6
- package/src/assessments/agentAssessor.ts +324 -0
- package/src/assessments/assessmentStore.ts +426 -0
- package/src/assessments/confirmCli.ts +29 -0
- package/src/config.ts +178 -4
- package/src/direct/directAudit.ts +400 -0
- package/src/direct/directGuards.ts +279 -0
- package/src/direct/directPatch.ts +258 -0
- package/src/direct/directSessionStore.ts +345 -0
- package/src/direct/directVerification.ts +138 -0
- package/src/doctor.ts +103 -7
- package/src/logging.ts +152 -0
- package/src/runner/agentInvocation.ts +125 -0
- package/src/runner/changeCapture.ts +352 -6
- package/src/runner/runTask.ts +279 -63
- package/src/runner/simpleProcess.ts +223 -0
- package/src/runner/watch.ts +18 -2
- package/src/security/commandGuard.ts +46 -4
- package/src/security/riskEngine.ts +160 -0
- package/src/security/runtimeGuard.ts +41 -0
- package/src/smoke-test.ts +1291 -4
- package/src/test/unit/android-doctor.test.ts +158 -0
- package/src/test/unit/chinese-path.test.ts +106 -0
- package/src/test/unit/command-guard.test.ts +221 -0
- package/src/test/unit/direct-guards.test.ts +297 -0
- package/src/test/unit/logging.test.ts +325 -0
- package/src/test/unit/path-guard.test.ts +150 -0
- package/src/test/unit/safe-status.test.ts +187 -0
- package/src/test/unit/sensitive-guard.test.ts +124 -0
- package/src/test/unit/sync-file.test.ts +231 -0
- package/src/test/unit/watcher-status.test.ts +190 -0
- package/src/tools/androidDoctor.ts +424 -0
- package/src/tools/applyPatch.ts +86 -0
- package/src/tools/auditSession.ts +28 -0
- package/src/tools/auditTask.ts +111 -7
- package/src/tools/createDirectSession.ts +113 -0
- package/src/tools/createTask.ts +405 -55
- package/src/tools/finalizeDirectSession.ts +144 -0
- package/src/tools/getTaskSummary.ts +133 -2
- package/src/tools/healthCheck.ts +45 -6
- package/src/tools/readWorkspaceFile.ts +85 -2
- package/src/tools/registry.ts +305 -19
- package/src/tools/runVerification.ts +58 -0
- package/src/tools/safeStatus.ts +96 -0
- package/src/tools/savePlan.ts +57 -7
- package/src/tools/searchWorkspace.ts +275 -0
- package/src/tools/syncFile.ts +122 -0
- package/src/tools/taskTemplates.ts +2 -1
- package/src/tools/toolCatalog.ts +37 -6
- package/src/tools/waitForTask.ts +3 -3
- package/src/version.ts +2 -2
- package/src/watcherStatus.ts +101 -4
- package/tsconfig.json +18 -17
- package/Check-PatchWarden-Health.cmd +0 -6
- package/Reset-PatchWarden-Tunnel-Key.cmd +0 -6
- package/Restart-PatchWarden.cmd +0 -19
- package/Start-PatchWarden-Tunnel.cmd +0 -7
- package/docs/release-v0.3.0.md +0 -43
- package/docs/release-v0.4.0.md +0 -74
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { resolve, relative, isAbsolute } from "node:path";
|
|
2
|
+
import { existsSync, statSync, openSync, readSync, closeSync } from "node:fs";
|
|
3
|
+
import { PatchWardenError } from "../errors.js";
|
|
4
|
+
import { isSensitivePath } from "../security/sensitiveGuard.js";
|
|
5
|
+
import { getConfig } from "../config.js";
|
|
6
|
+
// ── Session state guards ───────────────────────────────────────────
|
|
7
|
+
export function guardDirectSessionActive(session) {
|
|
8
|
+
if (new Date(session.expires_at) < new Date()) {
|
|
9
|
+
throw new PatchWardenError("session_expired", `Direct session "${session.session_id}" expired at ${session.expires_at}.`, "Create a new direct session with create_direct_session.", true, { session_id: session.session_id, expires_at: session.expires_at });
|
|
10
|
+
}
|
|
11
|
+
if (session.finalized) {
|
|
12
|
+
throw new PatchWardenError("session_finalized", `Direct session "${session.session_id}" has been finalized. No further modifications are allowed.`, "Create a new direct session with create_direct_session to continue making changes.", true, { session_id: session.session_id, finalized_at: session.finalized_at });
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export function guardDirectSessionFinalized(session) {
|
|
16
|
+
if (!session.finalized) {
|
|
17
|
+
throw new PatchWardenError("session_not_finalized", `Direct session "${session.session_id}" has not been finalized. Call finalize_direct_session first.`, "Call finalize_direct_session before audit_session.", true, { session_id: session.session_id });
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
// ── Path guards ────────────────────────────────────────────────────
|
|
21
|
+
/**
|
|
22
|
+
* Resolve a relative path against the session's repo_path and verify it stays
|
|
23
|
+
* inside both the repo and the workspace root.
|
|
24
|
+
*/
|
|
25
|
+
export function guardDirectPath(filePath, resolvedRepoPath, workspaceRoot) {
|
|
26
|
+
const config = getConfig();
|
|
27
|
+
const resolved = resolve(resolvedRepoPath, filePath);
|
|
28
|
+
const normalizedRepo = resolve(resolvedRepoPath);
|
|
29
|
+
const normalizedWs = resolve(workspaceRoot);
|
|
30
|
+
// Check path is inside workspace root
|
|
31
|
+
const relToWs = relative(normalizedWs, resolved);
|
|
32
|
+
if (isAbsolute(relToWs) || relToWs.startsWith("..")) {
|
|
33
|
+
throw new PatchWardenError("path_outside_repo", `Path "${filePath}" resolves outside the session repo.`, "Use a relative path inside the session's repo_path.", true, { path: filePath, operation: "direct_path_access" });
|
|
34
|
+
}
|
|
35
|
+
// Check path is inside repo_path
|
|
36
|
+
const relToRepo = relative(normalizedRepo, resolved);
|
|
37
|
+
if (isAbsolute(relToRepo) || relToRepo.startsWith("..")) {
|
|
38
|
+
throw new PatchWardenError("path_outside_repo", `Path "${filePath}" is outside the session repo_path "${resolvedRepoPath}".`, "Use a relative path inside the session's repo_path.", true, { path: filePath, operation: "direct_path_access" });
|
|
39
|
+
}
|
|
40
|
+
return resolved;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Guard a read path: must be inside repo, not sensitive, not binary.
|
|
44
|
+
*/
|
|
45
|
+
export function guardDirectReadPath(filePath, resolvedRepoPath, workspaceRoot) {
|
|
46
|
+
const resolved = guardDirectPath(filePath, resolvedRepoPath, workspaceRoot);
|
|
47
|
+
const normalized = filePath.replace(/\\/g, "/");
|
|
48
|
+
// Block internal PatchWarden paths (sessions, tasks, plans, assessments)
|
|
49
|
+
if (normalized.startsWith(".patchwarden/") ||
|
|
50
|
+
normalized.includes("/.patchwarden/")) {
|
|
51
|
+
throw new PatchWardenError("internal_patchwarden_path_blocked", `Access denied: "${filePath}" is inside the internal .patchwarden directory.`, "Internal PatchWarden files cannot be accessed through Direct mode.", true, { path: filePath, operation: "direct_read" });
|
|
52
|
+
}
|
|
53
|
+
if (isSensitivePath(filePath)) {
|
|
54
|
+
throw new PatchWardenError("sensitive_path_blocked", `Access denied: "${filePath}" matches a sensitive file pattern.`, "Read only non-sensitive files within the session repo.", true, { path: filePath, operation: "direct_read" });
|
|
55
|
+
}
|
|
56
|
+
if (existsSync(resolved) && statSync(resolved).isFile() && isBinaryFile(resolved)) {
|
|
57
|
+
throw new PatchWardenError("binary_file_blocked", `File "${filePath}" appears to be a binary file.`, "Binary files cannot be read in Direct mode.", true, { path: filePath, operation: "direct_read" });
|
|
58
|
+
}
|
|
59
|
+
return resolved;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Guard a write path: must be inside repo, not sensitive, not binary,
|
|
63
|
+
* not in node_modules, release, or dist directories.
|
|
64
|
+
*/
|
|
65
|
+
export function guardDirectWritePath(filePath, resolvedRepoPath, workspaceRoot) {
|
|
66
|
+
const resolved = guardDirectPath(filePath, resolvedRepoPath, workspaceRoot);
|
|
67
|
+
const normalized = filePath.replace(/\\/g, "/");
|
|
68
|
+
// Block internal PatchWarden paths
|
|
69
|
+
if (normalized.startsWith(".patchwarden/") ||
|
|
70
|
+
normalized.includes("/.patchwarden/")) {
|
|
71
|
+
throw new PatchWardenError("internal_patchwarden_path_blocked", `Modification denied: "${filePath}" is inside the internal .patchwarden directory.`, "Internal PatchWarden files cannot be modified through Direct mode.", true, { path: filePath, operation: "direct_write" });
|
|
72
|
+
}
|
|
73
|
+
if (isSensitivePath(normalized)) {
|
|
74
|
+
throw new PatchWardenError("sensitive_path_blocked", `Modification denied: "${filePath}" matches a sensitive file pattern.`, "Do not modify .env, credentials, keys, or other sensitive files.", true, { path: filePath, operation: "direct_write" });
|
|
75
|
+
}
|
|
76
|
+
// Block node_modules
|
|
77
|
+
if (normalized.startsWith("node_modules/") || normalized.includes("/node_modules/")) {
|
|
78
|
+
throw new PatchWardenError("blocked_artifact_path", `Modification denied: "${filePath}" is inside node_modules.`, "Do not modify dependencies directly. Use package management tools instead.", true, { path: filePath, operation: "direct_write" });
|
|
79
|
+
}
|
|
80
|
+
// Block release/
|
|
81
|
+
if (normalized.startsWith("release/") || normalized.includes("/release/")) {
|
|
82
|
+
throw new PatchWardenError("blocked_artifact_path", `Modification denied: "${filePath}" is inside the release directory.`, "Do not manually modify release artifacts.", true, { path: filePath, operation: "direct_write" });
|
|
83
|
+
}
|
|
84
|
+
// Block dist/
|
|
85
|
+
if (normalized.startsWith("dist/") || normalized.includes("/dist/")) {
|
|
86
|
+
throw new PatchWardenError("blocked_artifact_path", `Modification denied: "${filePath}" is inside the dist directory.`, "Do not manually modify build output. Modify source files instead.", true, { path: filePath, operation: "direct_write" });
|
|
87
|
+
}
|
|
88
|
+
if (existsSync(resolved) && statSync(resolved).isFile() && isBinaryFile(resolved)) {
|
|
89
|
+
throw new PatchWardenError("binary_file_blocked", `Modification denied: "${filePath}" appears to be a binary file.`, "Binary files cannot be modified in Direct mode.", true, { path: filePath, operation: "direct_write" });
|
|
90
|
+
}
|
|
91
|
+
return resolved;
|
|
92
|
+
}
|
|
93
|
+
// ── Size guards ────────────────────────────────────────────────────
|
|
94
|
+
export function guardDirectPatchSize(patchBytes) {
|
|
95
|
+
const config = getConfig();
|
|
96
|
+
if (patchBytes > config.directMaxPatchBytes) {
|
|
97
|
+
throw new PatchWardenError("patch_too_large", `Patch size ${patchBytes} bytes exceeds maximum ${config.directMaxPatchBytes} bytes.`, "Split the patch into smaller operations or reduce the patch size.", true, { patch_bytes: patchBytes, max_bytes: config.directMaxPatchBytes });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
export function guardDirectFileSize(fileBytes) {
|
|
101
|
+
const config = getConfig();
|
|
102
|
+
if (fileBytes > config.directMaxFileBytes) {
|
|
103
|
+
throw new PatchWardenError("file_too_large", `File size ${fileBytes} bytes exceeds maximum ${config.directMaxFileBytes} bytes.`, "Use search_workspace to find specific content, or split the read into smaller portions.", true, { file_bytes: fileBytes, max_bytes: config.directMaxFileBytes });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// ── Binary detection ───────────────────────────────────────────────
|
|
107
|
+
const BINARY_EXTENSIONS = new Set([
|
|
108
|
+
".exe", ".dll", ".so", ".dylib", ".a", ".lib", ".o", ".obj",
|
|
109
|
+
".zip", ".gz", ".tar", ".tgz", ".bz2", ".7z", ".rar",
|
|
110
|
+
".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", ".webp", ".tiff",
|
|
111
|
+
".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
|
|
112
|
+
".mp3", ".mp4", ".avi", ".mov", ".wav", ".flv",
|
|
113
|
+
".bin", ".dat", ".pak", ".pfx", ".p12", ".class", ".jar", ".war",
|
|
114
|
+
".wasm", ".node",
|
|
115
|
+
]);
|
|
116
|
+
export function isBinaryFile(filePath) {
|
|
117
|
+
const normalized = filePath.replace(/\\/g, "/").toLowerCase();
|
|
118
|
+
for (const ext of BINARY_EXTENSIONS) {
|
|
119
|
+
if (normalized.endsWith(ext))
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
// Check file content for null bytes (simple heuristic)
|
|
123
|
+
try {
|
|
124
|
+
const fd = openSync(filePath, "r");
|
|
125
|
+
const buffer = Buffer.alloc(8192);
|
|
126
|
+
const bytesRead = readSync(fd, buffer, 0, 8192, 0);
|
|
127
|
+
closeSync(fd);
|
|
128
|
+
for (let i = 0; i < bytesRead; i++) {
|
|
129
|
+
if (buffer[i] === 0)
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type PatchOperationType = "replace_exact" | "insert_before" | "insert_after" | "replace_whole_file";
|
|
2
|
+
export interface PatchOperation {
|
|
3
|
+
type: PatchOperationType;
|
|
4
|
+
old_text?: string;
|
|
5
|
+
new_text: string;
|
|
6
|
+
occurrence?: "first" | "all" | "exactly_once";
|
|
7
|
+
}
|
|
8
|
+
export interface ApplyPatchResult {
|
|
9
|
+
before_sha256: string;
|
|
10
|
+
after_sha256: string;
|
|
11
|
+
operations_applied: number;
|
|
12
|
+
bytes_changed: number;
|
|
13
|
+
}
|
|
14
|
+
export declare function computeFileSha256(filePath: string): string;
|
|
15
|
+
export declare function computeContentSha256(content: string): string;
|
|
16
|
+
export declare function validateExpectedSha256(filePath: string, expectedSha256: string): string;
|
|
17
|
+
export declare function applyPatchOperations(filePath: string, operations: PatchOperation[]): ApplyPatchResult;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import { PatchWardenError } from "../errors.js";
|
|
5
|
+
// ── Hash helpers ───────────────────────────────────────────────────
|
|
6
|
+
export function computeFileSha256(filePath) {
|
|
7
|
+
const content = readFileSync(filePath);
|
|
8
|
+
return createHash("sha256").update(content).digest("hex");
|
|
9
|
+
}
|
|
10
|
+
export function computeContentSha256(content) {
|
|
11
|
+
return createHash("sha256").update(content, "utf-8").digest("hex");
|
|
12
|
+
}
|
|
13
|
+
export function validateExpectedSha256(filePath, expectedSha256) {
|
|
14
|
+
if (!existsSync(filePath)) {
|
|
15
|
+
throw new PatchWardenError("file_hash_mismatch", `File does not exist: "${filePath}".`, "Ensure the file path is correct and the file exists before applying a patch.", true, { expected_sha256: expectedSha256 });
|
|
16
|
+
}
|
|
17
|
+
const currentHash = computeFileSha256(filePath);
|
|
18
|
+
if (currentHash !== expectedSha256) {
|
|
19
|
+
throw new PatchWardenError("file_hash_mismatch", `File hash mismatch. Expected "${expectedSha256}" but got "${currentHash}".`, "Re-read the file to get the current sha256, then retry the patch with the updated expected_sha256.", true, { expected_sha256: expectedSha256, actual_sha256: currentHash });
|
|
20
|
+
}
|
|
21
|
+
return currentHash;
|
|
22
|
+
}
|
|
23
|
+
// ── Patch application ──────────────────────────────────────────────
|
|
24
|
+
export function applyPatchOperations(filePath, operations) {
|
|
25
|
+
if (!existsSync(filePath)) {
|
|
26
|
+
throw new PatchWardenError("patch_anchor_not_found", `File does not exist: "${filePath}".`, "Ensure the file exists before applying a patch. Direct mode does not support creating new files.", true, { path: filePath });
|
|
27
|
+
}
|
|
28
|
+
const beforeContent = readFileSync(filePath, "utf-8");
|
|
29
|
+
const beforeSha256 = computeContentSha256(beforeContent);
|
|
30
|
+
let content = beforeContent;
|
|
31
|
+
let operationsApplied = 0;
|
|
32
|
+
for (const op of operations) {
|
|
33
|
+
content = applySingleOperation(content, op);
|
|
34
|
+
operationsApplied++;
|
|
35
|
+
}
|
|
36
|
+
const afterSha256 = computeContentSha256(content);
|
|
37
|
+
const bytesChanged = Math.abs(Buffer.byteLength(content, "utf-8") - Buffer.byteLength(beforeContent, "utf-8"));
|
|
38
|
+
// Ensure parent directory exists (should already exist for existing files)
|
|
39
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
40
|
+
writeFileSync(filePath, content, "utf-8");
|
|
41
|
+
return {
|
|
42
|
+
before_sha256: beforeSha256,
|
|
43
|
+
after_sha256: afterSha256,
|
|
44
|
+
operations_applied: operationsApplied,
|
|
45
|
+
bytes_changed: bytesChanged,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function applySingleOperation(content, op) {
|
|
49
|
+
switch (op.type) {
|
|
50
|
+
case "replace_exact":
|
|
51
|
+
return applyReplaceExact(content, op);
|
|
52
|
+
case "insert_before":
|
|
53
|
+
return applyInsertBefore(content, op);
|
|
54
|
+
case "insert_after":
|
|
55
|
+
return applyInsertAfter(content, op);
|
|
56
|
+
case "replace_whole_file":
|
|
57
|
+
return op.new_text;
|
|
58
|
+
default:
|
|
59
|
+
throw new PatchWardenError("patch_anchor_not_found", `Unknown operation type: "${op.type}".`, "Use one of: replace_exact, insert_before, insert_after, replace_whole_file.", true, { operation_type: op.type });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function applyReplaceExact(content, op) {
|
|
63
|
+
if (op.old_text === undefined || op.old_text === "") {
|
|
64
|
+
throw new PatchWardenError("patch_anchor_not_found", "replace_exact requires a non-empty old_text.", "Provide the exact text to replace.", true, { operation: "replace_exact" });
|
|
65
|
+
}
|
|
66
|
+
const occurrence = op.occurrence || "first";
|
|
67
|
+
const oldText = op.old_text;
|
|
68
|
+
const newText = op.new_text;
|
|
69
|
+
if (occurrence === "all") {
|
|
70
|
+
if (!content.includes(oldText)) {
|
|
71
|
+
throw new PatchWardenError("patch_anchor_not_found", `old_text not found in file content.`, "Ensure old_text exactly matches content in the file.", true, { operation: "replace_exact", occurrence: "all" });
|
|
72
|
+
}
|
|
73
|
+
return content.split(oldText).join(newText);
|
|
74
|
+
}
|
|
75
|
+
if (occurrence === "exactly_once") {
|
|
76
|
+
const firstIndex = content.indexOf(oldText);
|
|
77
|
+
if (firstIndex === -1) {
|
|
78
|
+
throw new PatchWardenError("patch_anchor_not_found", `old_text not found in file content.`, "Ensure old_text exactly matches content in the file.", true, { operation: "replace_exact", occurrence: "exactly_once" });
|
|
79
|
+
}
|
|
80
|
+
const secondIndex = content.indexOf(oldText, firstIndex + 1);
|
|
81
|
+
if (secondIndex !== -1) {
|
|
82
|
+
throw new PatchWardenError("patch_ambiguous", `old_text appears multiple times in file content, but occurrence is "exactly_once".`, "Use occurrence: \"first\" to replace the first match, or occurrence: \"all\" to replace all matches.", true, { operation: "replace_exact", occurrence: "exactly_once" });
|
|
83
|
+
}
|
|
84
|
+
return content.slice(0, firstIndex) + newText + content.slice(firstIndex + oldText.length);
|
|
85
|
+
}
|
|
86
|
+
// occurrence === "first" (default)
|
|
87
|
+
if (!content.includes(oldText)) {
|
|
88
|
+
throw new PatchWardenError("patch_anchor_not_found", `old_text not found in file content.`, "Ensure old_text exactly matches content in the file.", true, { operation: "replace_exact", occurrence: "first" });
|
|
89
|
+
}
|
|
90
|
+
const index = content.indexOf(oldText);
|
|
91
|
+
return content.slice(0, index) + newText + content.slice(index + oldText.length);
|
|
92
|
+
}
|
|
93
|
+
function applyInsertBefore(content, op) {
|
|
94
|
+
if (op.old_text === undefined || op.old_text === "") {
|
|
95
|
+
throw new PatchWardenError("patch_anchor_not_found", "insert_before requires a non-empty old_text as anchor.", "Provide the anchor text before which new_text should be inserted.", true, { operation: "insert_before" });
|
|
96
|
+
}
|
|
97
|
+
const index = content.indexOf(op.old_text);
|
|
98
|
+
if (index === -1) {
|
|
99
|
+
throw new PatchWardenError("patch_anchor_not_found", `Anchor text not found in file content for insert_before.`, "Ensure old_text exactly matches content in the file.", true, { operation: "insert_before" });
|
|
100
|
+
}
|
|
101
|
+
return content.slice(0, index) + op.new_text + content.slice(index);
|
|
102
|
+
}
|
|
103
|
+
function applyInsertAfter(content, op) {
|
|
104
|
+
if (op.old_text === undefined || op.old_text === "") {
|
|
105
|
+
throw new PatchWardenError("patch_anchor_not_found", "insert_after requires a non-empty old_text as anchor.", "Provide the anchor text after which new_text should be inserted.", true, { operation: "insert_after" });
|
|
106
|
+
}
|
|
107
|
+
const index = content.indexOf(op.old_text);
|
|
108
|
+
if (index === -1) {
|
|
109
|
+
throw new PatchWardenError("patch_anchor_not_found", `Anchor text not found in file content for insert_after.`, "Ensure old_text exactly matches content in the file.", true, { operation: "insert_after" });
|
|
110
|
+
}
|
|
111
|
+
const insertPos = index + op.old_text.length;
|
|
112
|
+
return content.slice(0, insertPos) + op.new_text + content.slice(insertPos);
|
|
113
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { type RepoSnapshot, type ChangeArtifacts } from "../runner/changeCapture.js";
|
|
2
|
+
export interface DirectSessionOperation {
|
|
3
|
+
index: number;
|
|
4
|
+
timestamp: string;
|
|
5
|
+
path: string;
|
|
6
|
+
before_sha256: string;
|
|
7
|
+
after_sha256: string;
|
|
8
|
+
operations_applied: number;
|
|
9
|
+
bytes_changed: number;
|
|
10
|
+
}
|
|
11
|
+
export interface DirectSessionVerificationRun {
|
|
12
|
+
command: string;
|
|
13
|
+
exit_code: number | null;
|
|
14
|
+
passed: boolean;
|
|
15
|
+
timed_out: boolean;
|
|
16
|
+
stdout_tail: string;
|
|
17
|
+
stderr_tail: string;
|
|
18
|
+
started_at: string;
|
|
19
|
+
finished_at: string;
|
|
20
|
+
log_path: string;
|
|
21
|
+
}
|
|
22
|
+
export interface DirectSessionRecord {
|
|
23
|
+
session_id: string;
|
|
24
|
+
title: string;
|
|
25
|
+
repo_path: string;
|
|
26
|
+
resolved_repo_path: string;
|
|
27
|
+
created_at: string;
|
|
28
|
+
expires_at: string;
|
|
29
|
+
server_version: string;
|
|
30
|
+
schema_epoch: string;
|
|
31
|
+
tool_manifest_sha256: string;
|
|
32
|
+
workspace_snapshot_before: RepoSnapshot;
|
|
33
|
+
workspace_fingerprint_before: string;
|
|
34
|
+
allowed_commands: string[];
|
|
35
|
+
operations: DirectSessionOperation[];
|
|
36
|
+
verification_runs: DirectSessionVerificationRun[];
|
|
37
|
+
finalized: boolean;
|
|
38
|
+
finalized_at: string | null;
|
|
39
|
+
audited: boolean;
|
|
40
|
+
change_artifacts: ChangeArtifacts | null;
|
|
41
|
+
}
|
|
42
|
+
export interface DirectSessionCreateInput {
|
|
43
|
+
repo_path: string;
|
|
44
|
+
resolved_repo_path: string;
|
|
45
|
+
title?: string;
|
|
46
|
+
snapshot: RepoSnapshot;
|
|
47
|
+
}
|
|
48
|
+
export declare function generateDirectSessionId(): string;
|
|
49
|
+
export declare function createDirectSessionDir(sessionId: string): string;
|
|
50
|
+
export declare function getDirectSessionDir(sessionId: string): string;
|
|
51
|
+
export declare function createDirectSession(input: DirectSessionCreateInput): DirectSessionRecord;
|
|
52
|
+
export declare function readDirectSession(sessionId: string): DirectSessionRecord;
|
|
53
|
+
export declare function updateDirectSession(sessionId: string, updates: Partial<DirectSessionRecord>): DirectSessionRecord;
|
|
54
|
+
export declare function appendDirectSessionOperation(sessionId: string, operation: DirectSessionOperation): DirectSessionRecord;
|
|
55
|
+
export declare function appendDirectSessionVerificationRun(sessionId: string, run: DirectSessionVerificationRun): DirectSessionRecord;
|
|
56
|
+
export interface DirectSessionValidationResult {
|
|
57
|
+
valid: boolean;
|
|
58
|
+
failure_reason: string | null;
|
|
59
|
+
session: DirectSessionRecord | null;
|
|
60
|
+
}
|
|
61
|
+
export declare function validateDirectSessionFreshness(sessionId: string): DirectSessionValidationResult;
|
|
62
|
+
export declare function finalizeDirectSessionRecord(sessionId: string, afterSnapshot: RepoSnapshot, changeArtifacts: ChangeArtifacts): DirectSessionRecord;
|
|
63
|
+
export declare function computeWorkspaceFingerprint(snapshot: RepoSnapshot): string;
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync, existsSync, readFileSync, } from "node:fs";
|
|
2
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
+
import { resolve, join } from "node:path";
|
|
4
|
+
import { getDirectSessionsDir, getConfig, getRepoDirectAllowedCommands, } from "../config.js";
|
|
5
|
+
import { guardPath } from "../security/pathGuard.js";
|
|
6
|
+
import { PatchWardenError } from "../errors.js";
|
|
7
|
+
import { PATCHWARDEN_VERSION, TOOL_SCHEMA_EPOCH } from "../version.js";
|
|
8
|
+
import { getLastToolCatalogSnapshot } from "../tools/toolCatalog.js";
|
|
9
|
+
// ── ID generation ──────────────────────────────────────────────────
|
|
10
|
+
export function generateDirectSessionId() {
|
|
11
|
+
const timestamp = new Date()
|
|
12
|
+
.toISOString()
|
|
13
|
+
.replace(/[-:]/g, "")
|
|
14
|
+
.replace("T", "_")
|
|
15
|
+
.slice(0, 15);
|
|
16
|
+
const randomHex = randomBytes(16).toString("hex");
|
|
17
|
+
return `direct_${timestamp}_${randomHex}`;
|
|
18
|
+
}
|
|
19
|
+
// ── Directory management ───────────────────────────────────────────
|
|
20
|
+
export function createDirectSessionDir(sessionId) {
|
|
21
|
+
const config = getConfig();
|
|
22
|
+
const sessionsDir = getDirectSessionsDir(config);
|
|
23
|
+
guardPath(sessionsDir, config.workspaceRoot, config.directSessionsDir);
|
|
24
|
+
mkdirSync(sessionsDir, { recursive: true });
|
|
25
|
+
const dir = resolve(sessionsDir, sessionId);
|
|
26
|
+
guardPath(dir, config.workspaceRoot, config.directSessionsDir);
|
|
27
|
+
mkdirSync(dir, { recursive: true });
|
|
28
|
+
return dir;
|
|
29
|
+
}
|
|
30
|
+
export function getDirectSessionDir(sessionId) {
|
|
31
|
+
const config = getConfig();
|
|
32
|
+
const sessionsDir = getDirectSessionsDir(config);
|
|
33
|
+
return resolve(sessionsDir, sessionId);
|
|
34
|
+
}
|
|
35
|
+
// ── CRUD ───────────────────────────────────────────────────────────
|
|
36
|
+
export function createDirectSession(input) {
|
|
37
|
+
const config = getConfig();
|
|
38
|
+
const sessionId = generateDirectSessionId();
|
|
39
|
+
const dir = createDirectSessionDir(sessionId);
|
|
40
|
+
const toolManifest = getLastToolCatalogSnapshot()?.tool_manifest_sha256 ||
|
|
41
|
+
computeFallbackManifestHash(config);
|
|
42
|
+
const workspaceFingerprint = computeWorkspaceFingerprint(input.snapshot);
|
|
43
|
+
const allowedCommands = [
|
|
44
|
+
...(config.directAllowedCommands || []),
|
|
45
|
+
...getRepoDirectAllowedCommands(config, input.resolved_repo_path),
|
|
46
|
+
];
|
|
47
|
+
const now = new Date();
|
|
48
|
+
const expiresAt = new Date(now.getTime() + config.directSessionTtlSeconds * 1000).toISOString();
|
|
49
|
+
const record = {
|
|
50
|
+
session_id: sessionId,
|
|
51
|
+
title: input.title || "",
|
|
52
|
+
repo_path: input.repo_path,
|
|
53
|
+
resolved_repo_path: input.resolved_repo_path,
|
|
54
|
+
created_at: now.toISOString(),
|
|
55
|
+
expires_at: expiresAt,
|
|
56
|
+
server_version: PATCHWARDEN_VERSION,
|
|
57
|
+
schema_epoch: TOOL_SCHEMA_EPOCH,
|
|
58
|
+
tool_manifest_sha256: toolManifest,
|
|
59
|
+
workspace_snapshot_before: input.snapshot,
|
|
60
|
+
workspace_fingerprint_before: workspaceFingerprint,
|
|
61
|
+
allowed_commands: allowedCommands,
|
|
62
|
+
operations: [],
|
|
63
|
+
verification_runs: [],
|
|
64
|
+
finalized: false,
|
|
65
|
+
finalized_at: null,
|
|
66
|
+
audited: false,
|
|
67
|
+
change_artifacts: null,
|
|
68
|
+
};
|
|
69
|
+
writeFileSync(join(dir, "session.json"), JSON.stringify(record, null, 2), "utf-8");
|
|
70
|
+
return record;
|
|
71
|
+
}
|
|
72
|
+
export function readDirectSession(sessionId) {
|
|
73
|
+
const config = getConfig();
|
|
74
|
+
const sessionsDir = getDirectSessionsDir(config);
|
|
75
|
+
const dir = resolve(sessionsDir, sessionId);
|
|
76
|
+
const file = join(dir, "session.json");
|
|
77
|
+
guardPath(file, config.workspaceRoot, config.directSessionsDir);
|
|
78
|
+
if (!existsSync(file)) {
|
|
79
|
+
throw new PatchWardenError("session_not_found", `Direct session "${sessionId}" not found.`, "Call create_direct_session first to get a valid session_id.", true, { session_id: sessionId });
|
|
80
|
+
}
|
|
81
|
+
return JSON.parse(readFileSync(file, "utf-8"));
|
|
82
|
+
}
|
|
83
|
+
export function updateDirectSession(sessionId, updates) {
|
|
84
|
+
const session = readDirectSession(sessionId);
|
|
85
|
+
const updated = { ...session, ...updates };
|
|
86
|
+
const dir = getDirectSessionDir(sessionId);
|
|
87
|
+
writeFileSync(join(dir, "session.json"), JSON.stringify(updated, null, 2), "utf-8");
|
|
88
|
+
return updated;
|
|
89
|
+
}
|
|
90
|
+
export function appendDirectSessionOperation(sessionId, operation) {
|
|
91
|
+
const session = readDirectSession(sessionId);
|
|
92
|
+
const operations = [...session.operations, operation];
|
|
93
|
+
return updateDirectSession(sessionId, { operations });
|
|
94
|
+
}
|
|
95
|
+
export function appendDirectSessionVerificationRun(sessionId, run) {
|
|
96
|
+
const session = readDirectSession(sessionId);
|
|
97
|
+
const verification_runs = [...session.verification_runs, run];
|
|
98
|
+
return updateDirectSession(sessionId, { verification_runs });
|
|
99
|
+
}
|
|
100
|
+
export function validateDirectSessionFreshness(sessionId) {
|
|
101
|
+
let session;
|
|
102
|
+
try {
|
|
103
|
+
session = readDirectSession(sessionId);
|
|
104
|
+
}
|
|
105
|
+
catch (e) {
|
|
106
|
+
return {
|
|
107
|
+
valid: false,
|
|
108
|
+
failure_reason: extractReason(e),
|
|
109
|
+
session: null,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
if (new Date(session.expires_at) < new Date()) {
|
|
113
|
+
return { valid: false, failure_reason: "session_expired", session };
|
|
114
|
+
}
|
|
115
|
+
const currentManifest = getLastToolCatalogSnapshot()?.tool_manifest_sha256 ||
|
|
116
|
+
computeFallbackManifestHash(getConfig());
|
|
117
|
+
if (currentManifest !== session.tool_manifest_sha256) {
|
|
118
|
+
return {
|
|
119
|
+
valid: false,
|
|
120
|
+
failure_reason: "session_stale_config",
|
|
121
|
+
session,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
return { valid: true, failure_reason: null, session };
|
|
125
|
+
}
|
|
126
|
+
// ── Finalization ───────────────────────────────────────────────────
|
|
127
|
+
export function finalizeDirectSessionRecord(sessionId, afterSnapshot, changeArtifacts) {
|
|
128
|
+
const dir = getDirectSessionDir(sessionId);
|
|
129
|
+
// Write change artifacts to session directory
|
|
130
|
+
writeFileSync(join(dir, "changed-files.json"), JSON.stringify(changeArtifacts.changed_files, null, 2), "utf-8");
|
|
131
|
+
writeFileSync(join(dir, "diff.patch"), changeArtifacts.diff, "utf-8");
|
|
132
|
+
const summary = {
|
|
133
|
+
changed_files_total: changeArtifacts.changed_files.length,
|
|
134
|
+
additions: changeArtifacts.additions,
|
|
135
|
+
deletions: changeArtifacts.deletions,
|
|
136
|
+
patch_mode: changeArtifacts.patch_mode,
|
|
137
|
+
artifact_hygiene: changeArtifacts.artifact_hygiene,
|
|
138
|
+
};
|
|
139
|
+
writeFileSync(join(dir, "summary.json"), JSON.stringify(summary, null, 2), "utf-8");
|
|
140
|
+
writeFileSync(join(dir, "summary.md"), formatSummaryMd(changeArtifacts), "utf-8");
|
|
141
|
+
return updateDirectSession(sessionId, {
|
|
142
|
+
finalized: true,
|
|
143
|
+
finalized_at: new Date().toISOString(),
|
|
144
|
+
change_artifacts: changeArtifacts,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
// ── Helpers ────────────────────────────────────────────────────────
|
|
148
|
+
export function computeWorkspaceFingerprint(snapshot) {
|
|
149
|
+
const fileHashes = Object.entries(snapshot.files)
|
|
150
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
151
|
+
.map(([path, fp]) => `${path}:${fp.sha256}`)
|
|
152
|
+
.join("\n");
|
|
153
|
+
return createHash("sha256")
|
|
154
|
+
.update(`${snapshot.head || "null"}\n${snapshot.status}\n${fileHashes}`)
|
|
155
|
+
.digest("hex");
|
|
156
|
+
}
|
|
157
|
+
function computeFallbackManifestHash(config) {
|
|
158
|
+
return createHash("sha256")
|
|
159
|
+
.update(`${TOOL_SCHEMA_EPOCH}:${config.toolProfile || "full"}`)
|
|
160
|
+
.digest("hex");
|
|
161
|
+
}
|
|
162
|
+
function extractReason(error) {
|
|
163
|
+
if (error instanceof PatchWardenError)
|
|
164
|
+
return error.reason;
|
|
165
|
+
if (error instanceof Error)
|
|
166
|
+
return error.message.slice(0, 80);
|
|
167
|
+
return String(error).slice(0, 80);
|
|
168
|
+
}
|
|
169
|
+
function formatSummaryMd(artifacts) {
|
|
170
|
+
const lines = [
|
|
171
|
+
"# Direct Session Change Summary",
|
|
172
|
+
"",
|
|
173
|
+
`**Changed files:** ${artifacts.changed_files.length}`,
|
|
174
|
+
`**Additions:** ${artifacts.additions}`,
|
|
175
|
+
`**Deletions:** ${artifacts.deletions}`,
|
|
176
|
+
`**Patch mode:** ${artifacts.patch_mode}`,
|
|
177
|
+
"",
|
|
178
|
+
"## Artifact Hygiene",
|
|
179
|
+
"",
|
|
180
|
+
`- Source changes: ${artifacts.artifact_hygiene.counts.source_changes}`,
|
|
181
|
+
`- Tracked build artifacts: ${artifacts.artifact_hygiene.counts.tracked_build_artifacts}`,
|
|
182
|
+
`- Runtime generated files: ${artifacts.artifact_hygiene.counts.runtime_generated_files}`,
|
|
183
|
+
`- Suspicious changes: ${artifacts.artifact_hygiene.counts.suspicious_changes}`,
|
|
184
|
+
"",
|
|
185
|
+
"## Changed Files",
|
|
186
|
+
"",
|
|
187
|
+
];
|
|
188
|
+
for (const file of artifacts.changed_files) {
|
|
189
|
+
lines.push(`- **${file.change}**: ${file.path}${file.old_path ? ` (from ${file.old_path})` : ""}`);
|
|
190
|
+
}
|
|
191
|
+
return lines.join("\n");
|
|
192
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { DirectSessionVerificationRun } from "./directSessionStore.js";
|
|
2
|
+
export interface RunDirectVerificationInput {
|
|
3
|
+
command: string;
|
|
4
|
+
resolvedRepoPath: string;
|
|
5
|
+
sessionId: string;
|
|
6
|
+
timeoutSeconds?: number;
|
|
7
|
+
}
|
|
8
|
+
export interface RunDirectVerificationResult {
|
|
9
|
+
run: DirectSessionVerificationRun;
|
|
10
|
+
log_path: string;
|
|
11
|
+
}
|
|
12
|
+
export declare function runDirectVerification(input: RunDirectVerificationInput): Promise<RunDirectVerificationResult>;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { getConfig } from "../config.js";
|
|
4
|
+
import { guardDirectCommand } from "../security/commandGuard.js";
|
|
5
|
+
import { redactSensitiveContent } from "../security/contentRedaction.js";
|
|
6
|
+
import { runSimpleProcess } from "../runner/simpleProcess.js";
|
|
7
|
+
import { getDirectSessionDir } from "./directSessionStore.js";
|
|
8
|
+
const MAX_TAIL_CHARS = 10_000;
|
|
9
|
+
const DEFAULT_TIMEOUT_SECONDS = 120;
|
|
10
|
+
export async function runDirectVerification(input) {
|
|
11
|
+
const config = getConfig();
|
|
12
|
+
const allowedCommand = guardDirectCommand(input.command, config, input.resolvedRepoPath);
|
|
13
|
+
const timeoutSeconds = input.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS;
|
|
14
|
+
const timeoutMs = timeoutSeconds * 1000;
|
|
15
|
+
// Parse command into command + args
|
|
16
|
+
const parts = parseCommand(allowedCommand);
|
|
17
|
+
if (!parts) {
|
|
18
|
+
throw new Error(`Failed to parse command: "${allowedCommand}"`);
|
|
19
|
+
}
|
|
20
|
+
const sessionDir = getDirectSessionDir(input.sessionId);
|
|
21
|
+
const logPath = join(sessionDir, "verification.log");
|
|
22
|
+
const startedAt = new Date().toISOString();
|
|
23
|
+
const result = await runSimpleProcess({
|
|
24
|
+
command: parts.command,
|
|
25
|
+
args: parts.args,
|
|
26
|
+
cwd: input.resolvedRepoPath,
|
|
27
|
+
timeoutMs,
|
|
28
|
+
stdoutPath: logPath,
|
|
29
|
+
stderrPath: logPath,
|
|
30
|
+
});
|
|
31
|
+
const finishedAt = new Date().toISOString();
|
|
32
|
+
// Redact and truncate output
|
|
33
|
+
const stdoutRedacted = redactSensitiveContent(result.stdout || "");
|
|
34
|
+
const stderrRedacted = redactSensitiveContent(result.stderr || "");
|
|
35
|
+
const stdoutTail = truncateTail(stdoutRedacted.content, MAX_TAIL_CHARS);
|
|
36
|
+
const stderrTail = truncateTail(stderrRedacted.content, MAX_TAIL_CHARS);
|
|
37
|
+
const passed = result.exitCode === 0 && !result.timedOut;
|
|
38
|
+
const run = {
|
|
39
|
+
command: allowedCommand,
|
|
40
|
+
exit_code: result.exitCode,
|
|
41
|
+
passed,
|
|
42
|
+
timed_out: result.timedOut,
|
|
43
|
+
stdout_tail: stdoutTail,
|
|
44
|
+
stderr_tail: stderrTail,
|
|
45
|
+
started_at: startedAt,
|
|
46
|
+
finished_at: finishedAt,
|
|
47
|
+
log_path: logPath,
|
|
48
|
+
};
|
|
49
|
+
// Write verification.json
|
|
50
|
+
const verificationJson = {
|
|
51
|
+
command: allowedCommand,
|
|
52
|
+
exit_code: result.exitCode,
|
|
53
|
+
passed,
|
|
54
|
+
timed_out: result.timedOut,
|
|
55
|
+
started_at: startedAt,
|
|
56
|
+
finished_at: finishedAt,
|
|
57
|
+
duration_ms: new Date(finishedAt).getTime() - new Date(startedAt).getTime(),
|
|
58
|
+
stdout_truncated: result.stdoutTruncated,
|
|
59
|
+
stderr_truncated: result.stderrTruncated,
|
|
60
|
+
redacted: stdoutRedacted.redacted || stderrRedacted.redacted,
|
|
61
|
+
redaction_categories: [
|
|
62
|
+
...new Set([
|
|
63
|
+
...stdoutRedacted.redaction_categories,
|
|
64
|
+
...stderrRedacted.redaction_categories,
|
|
65
|
+
]),
|
|
66
|
+
],
|
|
67
|
+
spawn_error: result.spawnError,
|
|
68
|
+
};
|
|
69
|
+
writeFileSync(join(sessionDir, "verification.json"), JSON.stringify(verificationJson, null, 2), "utf-8");
|
|
70
|
+
return { run, log_path: logPath };
|
|
71
|
+
}
|
|
72
|
+
// ── Helpers ────────────────────────────────────────────────────────
|
|
73
|
+
function parseCommand(command) {
|
|
74
|
+
const trimmed = command.trim();
|
|
75
|
+
if (!trimmed)
|
|
76
|
+
return null;
|
|
77
|
+
// Simple split by spaces - sufficient for allowlisted commands like "npm test"
|
|
78
|
+
const parts = trimmed.split(/\s+/);
|
|
79
|
+
// On Windows, npm/npx are batch files (.cmd) and spawn() cannot find them
|
|
80
|
+
// without shell resolution. Wrap with cmd.exe /c for those commands.
|
|
81
|
+
if (process.platform === "win32" && (parts[0] === "npm" || parts[0] === "npx")) {
|
|
82
|
+
return {
|
|
83
|
+
command: "cmd",
|
|
84
|
+
args: ["/c", ...parts],
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
command: parts[0],
|
|
89
|
+
args: parts.slice(1),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
function truncateTail(content, maxChars) {
|
|
93
|
+
if (content.length <= maxChars)
|
|
94
|
+
return content;
|
|
95
|
+
return "...(truncated)\n" + content.slice(content.length - maxChars);
|
|
96
|
+
}
|