patchwarden 1.5.0 → 1.5.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/README.en.md +43 -2
- package/README.md +43 -2
- package/dist/assessments/agentAssessor.d.ts +1 -1
- package/dist/assessments/agentAssessor.js +3 -3
- package/dist/assessments/assessmentStore.d.ts +1 -1
- package/dist/assessments/assessmentStore.js +2 -2
- package/dist/assessments/confirmCli.js +5 -4
- package/dist/config.d.ts +7 -0
- package/dist/config.js +34 -0
- package/dist/control/middleware/auth.d.ts +10 -0
- package/dist/control/middleware/auth.js +8 -0
- package/dist/control/middleware/static.d.ts +3 -0
- package/dist/control/middleware/static.js +65 -0
- package/dist/control/routes/audit.d.ts +15 -0
- package/dist/control/routes/audit.js +277 -0
- package/dist/control/routes/evidence.d.ts +4 -0
- package/dist/control/routes/evidence.js +96 -0
- package/dist/control/routes/lineage.d.ts +3 -0
- package/dist/control/routes/lineage.js +71 -0
- package/dist/control/routes/policy.d.ts +3 -0
- package/dist/control/routes/policy.js +81 -0
- package/dist/control/routes/process.d.ts +5 -0
- package/dist/control/routes/process.js +200 -0
- package/dist/control/routes/sessions.d.ts +22 -0
- package/dist/control/routes/sessions.js +224 -0
- package/dist/control/routes/status.d.ts +6 -0
- package/dist/control/routes/status.js +250 -0
- package/dist/control/routes/taskActions.d.ts +21 -0
- package/dist/control/routes/taskActions.js +233 -0
- package/dist/control/routes/tasks.d.ts +20 -0
- package/dist/control/routes/tasks.js +310 -0
- package/dist/control/routes/workspace.d.ts +15 -0
- package/dist/control/routes/workspace.js +193 -0
- package/dist/control/runtime.d.ts +91 -0
- package/dist/control/runtime.js +392 -0
- package/dist/control/server.d.ts +13 -0
- package/dist/control/server.js +479 -0
- package/dist/control/shared.d.ts +35 -0
- package/dist/control/shared.js +288 -0
- package/dist/controlCenter.d.ts +6 -0
- package/dist/controlCenter.js +7 -2197
- package/dist/direct/directGuards.js +30 -8
- package/dist/doctor.d.ts +18 -1
- package/dist/doctor.js +579 -348
- package/dist/goal/goalReport.d.ts +54 -0
- package/dist/goal/goalReport.js +204 -0
- package/dist/goal/goalStatus.d.ts +6 -0
- package/dist/goal/specKitImport.d.ts +63 -0
- package/dist/goal/specKitImport.js +220 -0
- package/dist/goal/subgoalSync.js +2 -1
- package/dist/httpServer.js +15 -12
- package/dist/index.js +7 -4
- package/dist/logging.d.ts +7 -1
- package/dist/logging.js +8 -0
- package/dist/runner/changeCapture.d.ts +3 -3
- package/dist/runner/changeCapture.js +63 -39
- package/dist/runner/cli.js +7 -6
- package/dist/runner/postTaskCleanup.js +26 -2
- package/dist/runner/runTask.js +245 -221
- package/dist/runner/simpleProcess.js +4 -4
- package/dist/runner/watch.js +17 -14
- package/dist/security/contentRedaction.d.ts +6 -0
- package/dist/security/contentRedaction.js +22 -0
- package/dist/smoke-test.js +257 -251
- package/dist/test/unit/apply-patch.test.d.ts +1 -0
- package/dist/test/unit/apply-patch.test.js +225 -0
- package/dist/test/unit/create-task.test.d.ts +1 -0
- package/dist/test/unit/create-task.test.js +197 -0
- package/dist/test/unit/direct-guards.test.js +124 -9
- package/dist/test/unit/evidence-pack.test.js +95 -1
- package/dist/test/unit/get-task-status.test.d.ts +1 -0
- package/dist/test/unit/get-task-status.test.js +174 -0
- package/dist/test/unit/get-task-summary.test.d.ts +1 -0
- package/dist/test/unit/get-task-summary.test.js +146 -0
- package/dist/test/unit/goal-report.test.d.ts +1 -0
- package/dist/test/unit/goal-report.test.js +159 -0
- package/dist/test/unit/goal-subgoal-task.test.js +6 -6
- package/dist/test/unit/goal-tools-registry.test.js +6 -4
- package/dist/test/unit/path-guard.test.js +24 -0
- package/dist/test/unit/spec-kit-import.test.d.ts +1 -0
- package/dist/test/unit/spec-kit-import.test.js +341 -0
- package/dist/test/unit/wait-for-task.test.d.ts +1 -0
- package/dist/test/unit/wait-for-task.test.js +144 -0
- package/dist/tools/auditTask.d.ts +8 -63
- package/dist/tools/auditTask.js +12 -8
- package/dist/tools/createDirectSession.d.ts +1 -1
- package/dist/tools/createDirectSession.js +2 -2
- package/dist/tools/createTask.d.ts +2 -2
- package/dist/tools/createTask.js +4 -4
- package/dist/tools/dispatch/coreDispatch.d.ts +9 -0
- package/dist/tools/dispatch/coreDispatch.js +282 -0
- package/dist/tools/dispatch/diagnosticDispatch.d.ts +14 -0
- package/dist/tools/dispatch/diagnosticDispatch.js +78 -0
- package/dist/tools/dispatch/directDispatch.d.ts +8 -0
- package/dist/tools/dispatch/directDispatch.js +115 -0
- package/dist/tools/dispatch/goalDispatch.d.ts +8 -0
- package/dist/tools/dispatch/goalDispatch.js +91 -0
- package/dist/tools/dispatch/releaseDispatch.d.ts +8 -0
- package/dist/tools/dispatch/releaseDispatch.js +45 -0
- package/dist/tools/dispatch/types.d.ts +23 -0
- package/dist/tools/dispatch/types.js +15 -0
- package/dist/tools/evidencePack.d.ts +6 -0
- package/dist/tools/evidencePack.js +213 -6
- package/dist/tools/finalizeDirectSession.d.ts +1 -1
- package/dist/tools/finalizeDirectSession.js +3 -3
- package/dist/tools/goalSubgoalTask.d.ts +1 -1
- package/dist/tools/goalSubgoalTask.js +2 -2
- package/dist/tools/healthCheck.js +3 -3
- package/dist/tools/registry.d.ts +3 -3
- package/dist/tools/registry.js +60 -503
- package/dist/tools/retryTask.d.ts +2 -2
- package/dist/tools/retryTask.js +2 -2
- package/dist/tools/runTaskLoop.js +4 -4
- package/dist/tools/safeViews.d.ts +2 -2
- package/dist/tools/safeViews.js +2 -2
- package/dist/tools/toolRegistry.js +22 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/docs/CODE_WIKI.md +983 -0
- package/docs/agentseal-integration.md +150 -0
- package/docs/architecture.md +63 -0
- package/docs/assets/patchwarden-oss-demo.gif +0 -0
- package/docs/dashboard-overview.md +86 -0
- package/docs/demo.md +8 -0
- package/docs/direct-session-workflow.md +98 -0
- package/docs/evidence-pack-schema.md +215 -0
- package/docs/execution-plan-2026-07-09.md +315 -0
- package/docs/lineage-evidence-pack-workflow.md +127 -0
- package/docs/mcp-inspector-testing.md +200 -0
- package/docs/open-source-application.md +162 -0
- package/docs/opencode-worker.md +151 -0
- package/docs/openhands-worker.md +181 -0
- package/docs/release-evidence.md +72 -0
- package/docs/roadmap-execution-and-acceptance.md +365 -0
- package/docs/spec-kit-integration.md +131 -0
- package/docs/task-safe-review-workflow.md +98 -0
- package/docs/threat-model.md +79 -0
- package/docs/user-feedback.md +40 -0
- package/docs/why-patchwarden.md +110 -0
- package/package.json +2 -2
- package/scripts/checks/control-center-smoke.js +356 -0
- package/scripts/checks/lifecycle-smoke.js +29 -23
- package/scripts/checks/mcp-smoke.js +3 -1
- package/scripts/generate-demo-gif.py +320 -0
- package/src/assessments/agentAssessor.ts +3 -3
- package/src/assessments/assessmentStore.ts +2 -2
- package/src/assessments/confirmCli.ts +5 -4
- package/src/config.ts +37 -0
- package/src/control/middleware/auth.ts +17 -0
- package/src/control/middleware/static.ts +71 -0
- package/src/control/routes/audit.ts +321 -0
- package/src/control/routes/evidence.ts +107 -0
- package/src/control/routes/lineage.ts +92 -0
- package/src/control/routes/policy.ts +81 -0
- package/src/control/routes/process.ts +204 -0
- package/src/control/routes/sessions.ts +251 -0
- package/src/control/routes/status.ts +325 -0
- package/src/control/routes/taskActions.ts +248 -0
- package/src/control/routes/tasks.ts +323 -0
- package/src/control/routes/workspace.ts +203 -0
- package/src/control/runtime.ts +472 -0
- package/src/control/server.ts +471 -0
- package/src/control/shared.ts +294 -0
- package/src/controlCenter.ts +7 -2347
- package/src/direct/directGuards.ts +28 -7
- package/src/doctor.ts +741 -481
- package/src/goal/goalReport.ts +271 -0
- package/src/goal/goalStatus.ts +6 -0
- package/src/goal/specKitImport.ts +355 -0
- package/src/goal/subgoalSync.ts +4 -2
- package/src/httpServer.ts +17 -14
- package/src/index.ts +7 -4
- package/src/logging.ts +10 -1
- package/src/runner/changeCapture.ts +70 -42
- package/src/runner/cli.ts +7 -6
- package/src/runner/postTaskCleanup.ts +26 -2
- package/src/runner/runTask.ts +325 -223
- package/src/runner/simpleProcess.ts +4 -4
- package/src/runner/watch.ts +17 -14
- package/src/security/contentRedaction.ts +29 -0
- package/src/smoke-test.ts +252 -250
- package/src/test/unit/apply-patch.test.ts +293 -0
- package/src/test/unit/create-task.test.ts +255 -0
- package/src/test/unit/direct-guards.test.ts +178 -8
- package/src/test/unit/evidence-pack.test.ts +110 -1
- package/src/test/unit/get-task-status.test.ts +203 -0
- package/src/test/unit/get-task-summary.test.ts +173 -0
- package/src/test/unit/goal-report.test.ts +189 -0
- package/src/test/unit/goal-subgoal-task.test.ts +6 -6
- package/src/test/unit/goal-tools-registry.test.ts +7 -5
- package/src/test/unit/path-guard.test.ts +30 -0
- package/src/test/unit/spec-kit-import.test.ts +429 -0
- package/src/test/unit/wait-for-task.test.ts +176 -0
- package/src/tools/auditTask.ts +99 -59
- package/src/tools/createDirectSession.ts +3 -3
- package/src/tools/createTask.ts +7 -7
- package/src/tools/dispatch/coreDispatch.ts +374 -0
- package/src/tools/dispatch/diagnosticDispatch.ts +101 -0
- package/src/tools/dispatch/directDispatch.ts +167 -0
- package/src/tools/dispatch/goalDispatch.ts +127 -0
- package/src/tools/dispatch/releaseDispatch.ts +65 -0
- package/src/tools/dispatch/types.ts +24 -0
- package/src/tools/evidencePack.ts +291 -6
- package/src/tools/finalizeDirectSession.ts +4 -4
- package/src/tools/goalSubgoalTask.ts +2 -2
- package/src/tools/healthCheck.ts +3 -3
- package/src/tools/registry.ts +68 -628
- package/src/tools/retryTask.ts +2 -2
- package/src/tools/runTaskLoop.ts +4 -4
- package/src/tools/safeViews.ts +2 -2
- package/src/tools/toolRegistry.ts +22 -0
- package/src/version.ts +1 -1
- package/ui/pages/audit.html +192 -3
- package/ui/pages/dashboard.html +959 -44
- package/ui/pages/direct-sessions.html +505 -53
- package/ui/pages/task-detail.html +456 -438
- package/ui/pages/tasks.html +598 -61
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
2
|
+
import { strict as assert } from "node:assert";
|
|
3
|
+
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import { applyPatch } from "../../tools/applyPatch.js";
|
|
8
|
+
import { createDirectSession } from "../../direct/directSessionStore.js";
|
|
9
|
+
import { reloadConfig } from "../../config.js";
|
|
10
|
+
import { PatchWardenError } from "../../errors.js";
|
|
11
|
+
import type { RepoSnapshot } from "../../runner/changeCapture.js";
|
|
12
|
+
import type { PatchOperation } from "../../direct/directPatch.js";
|
|
13
|
+
|
|
14
|
+
// ── Helpers ────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
function sha256(content: string): string {
|
|
17
|
+
return createHash("sha256").update(content, "utf-8").digest("hex");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function makeSnapshot(): RepoSnapshot {
|
|
21
|
+
return {
|
|
22
|
+
captured_at: new Date().toISOString(),
|
|
23
|
+
is_git: false,
|
|
24
|
+
head: null,
|
|
25
|
+
status: "",
|
|
26
|
+
workspace_dirty: false,
|
|
27
|
+
files: {},
|
|
28
|
+
dirty_paths: [],
|
|
29
|
+
warnings: [],
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function bootstrapWorkspace(prefix: string, maxPatchBytes: number): {
|
|
34
|
+
tempDir: string;
|
|
35
|
+
repoPath: string;
|
|
36
|
+
sessionId: string;
|
|
37
|
+
} {
|
|
38
|
+
const tempDir = mkdtempSync(join(tmpdir(), prefix));
|
|
39
|
+
const repoPath = join(tempDir, "my-repo");
|
|
40
|
+
mkdirSync(repoPath, { recursive: true });
|
|
41
|
+
mkdirSync(join(repoPath, "src"), { recursive: true });
|
|
42
|
+
|
|
43
|
+
const configPath = join(tempDir, "patchwarden.config.json");
|
|
44
|
+
writeFileSync(configPath, JSON.stringify({
|
|
45
|
+
workspaceRoot: tempDir,
|
|
46
|
+
agents: { codex: { command: "codex", args: ["exec", "{prompt}"] } },
|
|
47
|
+
allowedTestCommands: ["npm test"],
|
|
48
|
+
directMaxPatchBytes: maxPatchBytes,
|
|
49
|
+
directMaxFileBytes: 500_000,
|
|
50
|
+
}), "utf-8");
|
|
51
|
+
process.env.PATCHWARDEN_CONFIG = configPath;
|
|
52
|
+
reloadConfig();
|
|
53
|
+
|
|
54
|
+
const session = createDirectSession({
|
|
55
|
+
repo_path: "my-repo",
|
|
56
|
+
resolved_repo_path: repoPath,
|
|
57
|
+
title: "applyPatch test",
|
|
58
|
+
snapshot: makeSnapshot(),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
return { tempDir, repoPath, sessionId: session.session_id };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function teardownWorkspace(tempDir: string): void {
|
|
65
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
66
|
+
delete process.env.PATCHWARDEN_CONFIG;
|
|
67
|
+
reloadConfig();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── Success cases ──────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
describe("applyPatch success cases", () => {
|
|
73
|
+
let tempDir: string;
|
|
74
|
+
let repoPath: string;
|
|
75
|
+
let sessionId: string;
|
|
76
|
+
|
|
77
|
+
beforeEach(() => {
|
|
78
|
+
const ws = bootstrapWorkspace("pw-applypatch-ok-", 200_000);
|
|
79
|
+
tempDir = ws.tempDir;
|
|
80
|
+
repoPath = ws.repoPath;
|
|
81
|
+
sessionId = ws.sessionId;
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
afterEach(() => {
|
|
85
|
+
teardownWorkspace(tempDir);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("applies replace_exact operation", () => {
|
|
89
|
+
const filePath = join(repoPath, "src", "main.ts");
|
|
90
|
+
const original = "hello world";
|
|
91
|
+
writeFileSync(filePath, original, "utf-8");
|
|
92
|
+
|
|
93
|
+
const operations: PatchOperation[] = [
|
|
94
|
+
{ type: "replace_exact", old_text: "world", new_text: "there" },
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
const result = applyPatch({
|
|
98
|
+
session_id: sessionId,
|
|
99
|
+
path: "src/main.ts",
|
|
100
|
+
expected_sha256: sha256(original),
|
|
101
|
+
operations,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
assert.equal(result.path, "src/main.ts");
|
|
105
|
+
assert.equal(result.before_sha256, sha256(original));
|
|
106
|
+
assert.equal(result.after_sha256, sha256("hello there"));
|
|
107
|
+
assert.equal(result.operations_applied, 1);
|
|
108
|
+
assert.equal(result.bytes_changed, 0);
|
|
109
|
+
assert.ok(result.next_action.length > 0);
|
|
110
|
+
assert.equal(readFileSync(filePath, "utf-8"), "hello there");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("applies insert_before operation", () => {
|
|
114
|
+
const filePath = join(repoPath, "src", "main.ts");
|
|
115
|
+
const original = "hello world";
|
|
116
|
+
writeFileSync(filePath, original, "utf-8");
|
|
117
|
+
|
|
118
|
+
const operations: PatchOperation[] = [
|
|
119
|
+
{ type: "insert_before", old_text: "world", new_text: "beautiful " },
|
|
120
|
+
];
|
|
121
|
+
|
|
122
|
+
const result = applyPatch({
|
|
123
|
+
session_id: sessionId,
|
|
124
|
+
path: "src/main.ts",
|
|
125
|
+
expected_sha256: sha256(original),
|
|
126
|
+
operations,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
assert.equal(readFileSync(filePath, "utf-8"), "hello beautiful world");
|
|
130
|
+
assert.equal(result.after_sha256, sha256("hello beautiful world"));
|
|
131
|
+
assert.equal(result.operations_applied, 1);
|
|
132
|
+
assert.equal(result.bytes_changed, 10);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("applies insert_after operation", () => {
|
|
136
|
+
const filePath = join(repoPath, "src", "main.ts");
|
|
137
|
+
const original = "hello world";
|
|
138
|
+
writeFileSync(filePath, original, "utf-8");
|
|
139
|
+
|
|
140
|
+
const operations: PatchOperation[] = [
|
|
141
|
+
{ type: "insert_after", old_text: "hello", new_text: " beautiful" },
|
|
142
|
+
];
|
|
143
|
+
|
|
144
|
+
const result = applyPatch({
|
|
145
|
+
session_id: sessionId,
|
|
146
|
+
path: "src/main.ts",
|
|
147
|
+
expected_sha256: sha256(original),
|
|
148
|
+
operations,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
assert.equal(readFileSync(filePath, "utf-8"), "hello beautiful world");
|
|
152
|
+
assert.equal(result.after_sha256, sha256("hello beautiful world"));
|
|
153
|
+
assert.equal(result.operations_applied, 1);
|
|
154
|
+
assert.equal(result.bytes_changed, 10);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("applies replace_whole_file operation", () => {
|
|
158
|
+
const filePath = join(repoPath, "src", "main.ts");
|
|
159
|
+
const original = "hello world";
|
|
160
|
+
writeFileSync(filePath, original, "utf-8");
|
|
161
|
+
|
|
162
|
+
const newContent = "completely new content";
|
|
163
|
+
const operations: PatchOperation[] = [
|
|
164
|
+
{ type: "replace_whole_file", new_text: newContent },
|
|
165
|
+
];
|
|
166
|
+
|
|
167
|
+
const result = applyPatch({
|
|
168
|
+
session_id: sessionId,
|
|
169
|
+
path: "src/main.ts",
|
|
170
|
+
expected_sha256: sha256(original),
|
|
171
|
+
operations,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
assert.equal(readFileSync(filePath, "utf-8"), newContent);
|
|
175
|
+
assert.equal(result.after_sha256, sha256(newContent));
|
|
176
|
+
assert.equal(result.operations_applied, 1);
|
|
177
|
+
assert.equal(result.bytes_changed, 11);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// ── Rejection cases ────────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
describe("applyPatch rejection cases", () => {
|
|
184
|
+
let tempDir: string;
|
|
185
|
+
let repoPath: string;
|
|
186
|
+
let sessionId: string;
|
|
187
|
+
|
|
188
|
+
beforeEach(() => {
|
|
189
|
+
const ws = bootstrapWorkspace("pw-applypatch-rej-", 200_000);
|
|
190
|
+
tempDir = ws.tempDir;
|
|
191
|
+
repoPath = ws.repoPath;
|
|
192
|
+
sessionId = ws.sessionId;
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
afterEach(() => {
|
|
196
|
+
teardownWorkspace(tempDir);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("rejects when expected_sha256 does not match", () => {
|
|
200
|
+
const filePath = join(repoPath, "src", "main.ts");
|
|
201
|
+
writeFileSync(filePath, "hello world", "utf-8");
|
|
202
|
+
|
|
203
|
+
assert.throws(
|
|
204
|
+
() => applyPatch({
|
|
205
|
+
session_id: sessionId,
|
|
206
|
+
path: "src/main.ts",
|
|
207
|
+
expected_sha256: "0".repeat(64),
|
|
208
|
+
operations: [{ type: "replace_whole_file", new_text: "new" }],
|
|
209
|
+
}),
|
|
210
|
+
(err: unknown) =>
|
|
211
|
+
err instanceof PatchWardenError && err.reason === "file_hash_mismatch"
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
// File content must remain unchanged after rejection
|
|
215
|
+
assert.equal(readFileSync(filePath, "utf-8"), "hello world");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("rejects binary files (e.g. .png)", () => {
|
|
219
|
+
const filePath = join(repoPath, "src", "image.png");
|
|
220
|
+
writeFileSync(filePath, "fake png content", "utf-8");
|
|
221
|
+
|
|
222
|
+
assert.throws(
|
|
223
|
+
() => applyPatch({
|
|
224
|
+
session_id: sessionId,
|
|
225
|
+
path: "src/image.png",
|
|
226
|
+
expected_sha256: sha256("fake png content"),
|
|
227
|
+
operations: [{ type: "replace_whole_file", new_text: "new" }],
|
|
228
|
+
}),
|
|
229
|
+
(err: unknown) =>
|
|
230
|
+
err instanceof PatchWardenError && err.reason === "binary_file_blocked"
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
// File content must remain unchanged after rejection
|
|
234
|
+
assert.equal(readFileSync(filePath, "utf-8"), "fake png content");
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("rejects paths outside workspaceRoot", () => {
|
|
238
|
+
assert.throws(
|
|
239
|
+
() => applyPatch({
|
|
240
|
+
session_id: sessionId,
|
|
241
|
+
path: "../../../etc/passwd",
|
|
242
|
+
expected_sha256: sha256("dummy"),
|
|
243
|
+
operations: [{ type: "replace_whole_file", new_text: "new" }],
|
|
244
|
+
}),
|
|
245
|
+
PatchWardenError
|
|
246
|
+
);
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// ── Patch size limit ───────────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
describe("applyPatch patch size limit", () => {
|
|
253
|
+
let tempDir: string;
|
|
254
|
+
let repoPath: string;
|
|
255
|
+
let sessionId: string;
|
|
256
|
+
|
|
257
|
+
beforeEach(() => {
|
|
258
|
+
const ws = bootstrapWorkspace("pw-applypatch-size-", 100);
|
|
259
|
+
tempDir = ws.tempDir;
|
|
260
|
+
repoPath = ws.repoPath;
|
|
261
|
+
sessionId = ws.sessionId;
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
afterEach(() => {
|
|
265
|
+
teardownWorkspace(tempDir);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("rejects patches exceeding directMaxPatchBytes", () => {
|
|
269
|
+
const filePath = join(repoPath, "src", "main.ts");
|
|
270
|
+
writeFileSync(filePath, "hello world", "utf-8");
|
|
271
|
+
|
|
272
|
+
// Build a patch whose JSON.stringify length exceeds the 100-byte limit
|
|
273
|
+
const bigText = "x".repeat(80);
|
|
274
|
+
const operations: PatchOperation[] = [
|
|
275
|
+
{ type: "replace_whole_file", new_text: bigText },
|
|
276
|
+
];
|
|
277
|
+
assert.ok(JSON.stringify(operations).length > 100);
|
|
278
|
+
|
|
279
|
+
assert.throws(
|
|
280
|
+
() => applyPatch({
|
|
281
|
+
session_id: sessionId,
|
|
282
|
+
path: "src/main.ts",
|
|
283
|
+
expected_sha256: sha256("hello world"),
|
|
284
|
+
operations,
|
|
285
|
+
}),
|
|
286
|
+
(err: unknown) =>
|
|
287
|
+
err instanceof PatchWardenError && err.reason === "patch_too_large"
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
// File content must remain unchanged after rejection
|
|
291
|
+
assert.equal(readFileSync(filePath, "utf-8"), "hello world");
|
|
292
|
+
});
|
|
293
|
+
});
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
2
|
+
import { strict as assert } from "node:assert";
|
|
3
|
+
import {
|
|
4
|
+
mkdtempSync,
|
|
5
|
+
rmSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
existsSync,
|
|
9
|
+
readFileSync,
|
|
10
|
+
readdirSync,
|
|
11
|
+
} from "node:fs";
|
|
12
|
+
import { join, dirname } from "node:path";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { reloadConfig } from "../../config.js";
|
|
15
|
+
import { createTask, type CreateTaskOutput, type AssessOnlyOutput } from "../../tools/createTask.js";
|
|
16
|
+
import { savePlan } from "../../tools/savePlan.js";
|
|
17
|
+
import { PatchWardenError } from "../../errors.js";
|
|
18
|
+
|
|
19
|
+
// ── Helpers ───────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
let tempDir: string;
|
|
22
|
+
let prevConfigEnv: string | undefined;
|
|
23
|
+
|
|
24
|
+
function writeConfig(workspaceRoot: string): void {
|
|
25
|
+
const configPath = join(tempDir, "patchwarden.config.json");
|
|
26
|
+
writeFileSync(
|
|
27
|
+
configPath,
|
|
28
|
+
JSON.stringify({
|
|
29
|
+
workspaceRoot,
|
|
30
|
+
tasksDir: ".patchwarden/tasks",
|
|
31
|
+
plansDir: ".patchwarden/plans",
|
|
32
|
+
assessmentsDir: ".patchwarden/assessments",
|
|
33
|
+
directSessionsDir: ".patchwarden/direct-sessions",
|
|
34
|
+
agents: {
|
|
35
|
+
codex: { command: "codex", args: ["exec", "{prompt}"] },
|
|
36
|
+
},
|
|
37
|
+
allowedTestCommands: ["npm test", "npm run build"],
|
|
38
|
+
defaultTaskTimeoutSeconds: 60,
|
|
39
|
+
maxTaskTimeoutSeconds: 300,
|
|
40
|
+
watcherStaleSeconds: 30,
|
|
41
|
+
}),
|
|
42
|
+
"utf-8"
|
|
43
|
+
);
|
|
44
|
+
prevConfigEnv = process.env.PATCHWARDEN_CONFIG;
|
|
45
|
+
process.env.PATCHWARDEN_CONFIG = configPath;
|
|
46
|
+
reloadConfig();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Write a fresh watcher heartbeat so readWatcherStatus reports healthy. */
|
|
50
|
+
function writeFreshHeartbeat(workspaceRoot: string): void {
|
|
51
|
+
const heartbeatPath = join(workspaceRoot, ".patchwarden", "watcher-heartbeat.json");
|
|
52
|
+
mkdirSync(dirname(heartbeatPath), { recursive: true });
|
|
53
|
+
writeFileSync(
|
|
54
|
+
heartbeatPath,
|
|
55
|
+
JSON.stringify({
|
|
56
|
+
status: "running",
|
|
57
|
+
pid: process.pid,
|
|
58
|
+
last_heartbeat_at: new Date().toISOString(),
|
|
59
|
+
instance_id: "test-instance",
|
|
60
|
+
}),
|
|
61
|
+
"utf-8"
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe("createTask", () => {
|
|
66
|
+
beforeEach(() => {
|
|
67
|
+
tempDir = mkdtempSync(join(tmpdir(), "pw-createtask-"));
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
afterEach(() => {
|
|
71
|
+
if (prevConfigEnv === undefined) delete process.env.PATCHWARDEN_CONFIG;
|
|
72
|
+
else process.env.PATCHWARDEN_CONFIG = prevConfigEnv;
|
|
73
|
+
reloadConfig();
|
|
74
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// ── 1. inline_plan source ──────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
it("creates a task from inline_plan, writes status.json, and allocates a task directory", async () => {
|
|
80
|
+
writeConfig(tempDir);
|
|
81
|
+
mkdirSync(join(tempDir, "my-repo"), { recursive: true });
|
|
82
|
+
writeFreshHeartbeat(tempDir);
|
|
83
|
+
|
|
84
|
+
const result = await createTask({
|
|
85
|
+
inline_plan: "## Goal\nAdd a hello-world print statement to main.ts.",
|
|
86
|
+
agent: "codex",
|
|
87
|
+
repo_path: "my-repo",
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const out = result as CreateTaskOutput;
|
|
91
|
+
assert.ok(out.task_id.startsWith("task_"), `task_id should start with "task_", got: ${out.task_id}`);
|
|
92
|
+
assert.equal(out.status, "pending");
|
|
93
|
+
assert.equal(out.plan_source, "inline");
|
|
94
|
+
assert.equal(out.agent, "codex");
|
|
95
|
+
assert.equal(out.execution_blocked, false);
|
|
96
|
+
assert.ok(existsSync(out.path), "task directory should exist");
|
|
97
|
+
assert.ok(existsSync(join(out.path, "status.json")), "status.json should exist");
|
|
98
|
+
|
|
99
|
+
const status = JSON.parse(readFileSync(join(out.path, "status.json"), "utf-8"));
|
|
100
|
+
assert.equal(status.task_id, out.task_id);
|
|
101
|
+
assert.equal(status.status, "pending");
|
|
102
|
+
assert.equal(status.phase, "queued");
|
|
103
|
+
assert.equal(status.agent, "codex");
|
|
104
|
+
assert.equal(status.plan_source, "inline");
|
|
105
|
+
assert.equal(status.repo_path, "my-repo");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// ── 2. template source ─────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
it("creates a task from a built-in template (feature_small)", async () => {
|
|
111
|
+
writeConfig(tempDir);
|
|
112
|
+
mkdirSync(join(tempDir, "my-repo"), { recursive: true });
|
|
113
|
+
writeFreshHeartbeat(tempDir);
|
|
114
|
+
|
|
115
|
+
const result = await createTask({
|
|
116
|
+
template: "feature_small",
|
|
117
|
+
goal: "Add a utility function for string truncation.",
|
|
118
|
+
agent: "codex",
|
|
119
|
+
repo_path: "my-repo",
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const out = result as CreateTaskOutput;
|
|
123
|
+
assert.ok(out.task_id.startsWith("task_"));
|
|
124
|
+
assert.equal(out.plan_source, "template");
|
|
125
|
+
assert.equal(out.template, "feature_small");
|
|
126
|
+
assert.ok(existsSync(join(out.path, "status.json")));
|
|
127
|
+
|
|
128
|
+
const status = JSON.parse(readFileSync(join(out.path, "status.json"), "utf-8"));
|
|
129
|
+
assert.equal(status.template, "feature_small");
|
|
130
|
+
assert.equal(status.plan_source, "template");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// ── 3. plan_id source ──────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
it("creates a task from a previously saved plan_id", async () => {
|
|
136
|
+
writeConfig(tempDir);
|
|
137
|
+
mkdirSync(join(tempDir, "my-repo"), { recursive: true });
|
|
138
|
+
writeFreshHeartbeat(tempDir);
|
|
139
|
+
|
|
140
|
+
const saved = savePlan({
|
|
141
|
+
title: "Pre-saved plan",
|
|
142
|
+
content: "## Goal\nDo something safe and repo-local.",
|
|
143
|
+
});
|
|
144
|
+
const result = await createTask({
|
|
145
|
+
plan_id: saved.plan_id,
|
|
146
|
+
agent: "codex",
|
|
147
|
+
repo_path: "my-repo",
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const out = result as CreateTaskOutput;
|
|
151
|
+
assert.equal(out.plan_id, saved.plan_id);
|
|
152
|
+
assert.equal(out.plan_source, "saved");
|
|
153
|
+
assert.ok(existsSync(join(out.path, "status.json")));
|
|
154
|
+
|
|
155
|
+
const status = JSON.parse(readFileSync(join(out.path, "status.json"), "utf-8"));
|
|
156
|
+
assert.equal(status.plan_id, saved.plan_id);
|
|
157
|
+
assert.equal(status.plan_source, "saved");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// ── 4. assess_only mode ────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
it("returns a risk assessment instead of creating a task in assess_only mode", async () => {
|
|
163
|
+
writeConfig(tempDir);
|
|
164
|
+
mkdirSync(join(tempDir, "my-repo"), { recursive: true });
|
|
165
|
+
|
|
166
|
+
const result = await createTask({
|
|
167
|
+
inline_plan: "## Goal\nAdd a print statement to main.ts.",
|
|
168
|
+
agent: "codex",
|
|
169
|
+
repo_path: "my-repo",
|
|
170
|
+
execution_mode: "assess_only",
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const out = result as AssessOnlyOutput;
|
|
174
|
+
assert.ok(out.assessment_id, "assessment_id should be present");
|
|
175
|
+
assert.ok(out.assessment_short_id, "assessment_short_id should be present");
|
|
176
|
+
assert.equal(out.decision, "allow");
|
|
177
|
+
assert.equal(out.risk_level, "low");
|
|
178
|
+
assert.ok(out.next_tool_call, "next_tool_call should be present for allow decision");
|
|
179
|
+
assert.equal(out.next_tool_call!.name, "create_task");
|
|
180
|
+
assert.equal(out.next_tool_call!.arguments.execution_mode, "execute");
|
|
181
|
+
assert.equal(out.next_tool_call!.arguments.assessment_id, out.assessment_id);
|
|
182
|
+
|
|
183
|
+
// No task directory should be created in assess_only mode
|
|
184
|
+
assert.equal("task_id" in out, false, "assess_only output should not contain task_id");
|
|
185
|
+
const tasksDir = join(tempDir, ".patchwarden", "tasks");
|
|
186
|
+
if (existsSync(tasksDir)) {
|
|
187
|
+
const taskEntries = readdirSync(tasksDir).filter((name) => name.startsWith("task_"));
|
|
188
|
+
assert.equal(taskEntries.length, 0, "no task directory should be created in assess_only mode");
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// ── 5. agent not configured ────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
it("throws PatchWardenError (agent_not_configured) when the agent is not registered", async () => {
|
|
195
|
+
writeConfig(tempDir);
|
|
196
|
+
mkdirSync(join(tempDir, "my-repo"), { recursive: true });
|
|
197
|
+
|
|
198
|
+
await assert.rejects(
|
|
199
|
+
() =>
|
|
200
|
+
createTask({
|
|
201
|
+
inline_plan: "## Goal\nDo something.",
|
|
202
|
+
agent: "ghost-agent",
|
|
203
|
+
repo_path: "my-repo",
|
|
204
|
+
}),
|
|
205
|
+
(err: unknown) => {
|
|
206
|
+
assert.ok(err instanceof PatchWardenError, "should throw PatchWardenError");
|
|
207
|
+
assert.equal((err as PatchWardenError).reason, "agent_not_configured");
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// ── 6. repo_path escapes workspace ─────────────────────────────
|
|
214
|
+
|
|
215
|
+
it("throws workspace_path_escape when repo_path is outside workspaceRoot", async () => {
|
|
216
|
+
writeConfig(tempDir);
|
|
217
|
+
mkdirSync(join(tempDir, "my-repo"), { recursive: true });
|
|
218
|
+
|
|
219
|
+
await assert.rejects(
|
|
220
|
+
() =>
|
|
221
|
+
createTask({
|
|
222
|
+
inline_plan: "## Goal\nDo something.",
|
|
223
|
+
agent: "codex",
|
|
224
|
+
repo_path: "../outside-workspace",
|
|
225
|
+
}),
|
|
226
|
+
(err: unknown) => {
|
|
227
|
+
assert.ok(err instanceof PatchWardenError, "should throw PatchWardenError");
|
|
228
|
+
assert.equal((err as PatchWardenError).reason, "workspace_path_escape");
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
231
|
+
);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// ── 7. watcher not running ─────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
it("returns execution_blocked: true when watcher is not running", async () => {
|
|
237
|
+
writeConfig(tempDir);
|
|
238
|
+
mkdirSync(join(tempDir, "my-repo"), { recursive: true });
|
|
239
|
+
// No watcher heartbeat file → watcher status is "missing"
|
|
240
|
+
|
|
241
|
+
const result = await createTask({
|
|
242
|
+
inline_plan: "## Goal\nDo something.",
|
|
243
|
+
agent: "codex",
|
|
244
|
+
repo_path: "my-repo",
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
const out = result as CreateTaskOutput;
|
|
248
|
+
assert.equal(out.execution_blocked, true);
|
|
249
|
+
assert.equal(out.watcher.available, false);
|
|
250
|
+
assert.equal(out.watcher.status, "missing");
|
|
251
|
+
assert.equal(out.next_tool_call.name, "health_check");
|
|
252
|
+
// Task is still created (queued) despite being blocked
|
|
253
|
+
assert.ok(existsSync(join(out.path, "status.json")));
|
|
254
|
+
});
|
|
255
|
+
});
|