patchwarden 0.4.0
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/Check-PatchWarden-Health.cmd +6 -0
- package/LICENSE +21 -0
- package/README.md +526 -0
- package/Reset-PatchWarden-Tunnel-Key.cmd +6 -0
- package/Restart-PatchWarden.cmd +19 -0
- package/Start-PatchWarden-Tunnel.cmd +7 -0
- package/dist/config.d.ts +24 -0
- package/dist/config.js +131 -0
- package/dist/doctor.d.ts +10 -0
- package/dist/doctor.js +388 -0
- package/dist/errors.d.ts +16 -0
- package/dist/errors.js +27 -0
- package/dist/httpServer.d.ts +17 -0
- package/dist/httpServer.js +255 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +24 -0
- package/dist/runner/changeCapture.d.ts +43 -0
- package/dist/runner/changeCapture.js +245 -0
- package/dist/runner/cli.d.ts +9 -0
- package/dist/runner/cli.js +26 -0
- package/dist/runner/runTask.d.ts +8 -0
- package/dist/runner/runTask.js +821 -0
- package/dist/runner/watch.d.ts +19 -0
- package/dist/runner/watch.js +136 -0
- package/dist/security/commandGuard.d.ts +22 -0
- package/dist/security/commandGuard.js +56 -0
- package/dist/security/contentRedaction.d.ts +12 -0
- package/dist/security/contentRedaction.js +65 -0
- package/dist/security/pathGuard.d.ts +3 -0
- package/dist/security/pathGuard.js +98 -0
- package/dist/security/planGuard.d.ts +1 -0
- package/dist/security/planGuard.js +65 -0
- package/dist/security/sensitiveGuard.d.ts +2 -0
- package/dist/security/sensitiveGuard.js +60 -0
- package/dist/smoke-test.d.ts +15 -0
- package/dist/smoke-test.js +989 -0
- package/dist/taskProgress.d.ts +6 -0
- package/dist/taskProgress.js +36 -0
- package/dist/taskRuntime.d.ts +10 -0
- package/dist/taskRuntime.js +25 -0
- package/dist/tools/auditTask.d.ts +19 -0
- package/dist/tools/auditTask.js +307 -0
- package/dist/tools/cancelTask.d.ts +30 -0
- package/dist/tools/cancelTask.js +64 -0
- package/dist/tools/createTask.d.ts +42 -0
- package/dist/tools/createTask.js +188 -0
- package/dist/tools/getPlan.d.ts +12 -0
- package/dist/tools/getPlan.js +29 -0
- package/dist/tools/getTaskFile.d.ts +24 -0
- package/dist/tools/getTaskFile.js +84 -0
- package/dist/tools/getTaskProgress.d.ts +1 -0
- package/dist/tools/getTaskProgress.js +4 -0
- package/dist/tools/getTaskStatus.d.ts +46 -0
- package/dist/tools/getTaskStatus.js +36 -0
- package/dist/tools/getTaskStdoutTail.d.ts +9 -0
- package/dist/tools/getTaskStdoutTail.js +56 -0
- package/dist/tools/getTaskSummary.d.ts +66 -0
- package/dist/tools/getTaskSummary.js +237 -0
- package/dist/tools/healthCheck.d.ts +79 -0
- package/dist/tools/healthCheck.js +230 -0
- package/dist/tools/killTask.d.ts +15 -0
- package/dist/tools/killTask.js +4 -0
- package/dist/tools/listAgents.d.ts +12 -0
- package/dist/tools/listAgents.js +42 -0
- package/dist/tools/listTasks.d.ts +36 -0
- package/dist/tools/listTasks.js +94 -0
- package/dist/tools/listWorkspace.d.ts +10 -0
- package/dist/tools/listWorkspace.js +54 -0
- package/dist/tools/readWorkspaceFile.d.ts +8 -0
- package/dist/tools/readWorkspaceFile.js +26 -0
- package/dist/tools/registry.d.ts +24 -0
- package/dist/tools/registry.js +566 -0
- package/dist/tools/retryTask.d.ts +8 -0
- package/dist/tools/retryTask.js +45 -0
- package/dist/tools/savePlan.d.ts +10 -0
- package/dist/tools/savePlan.js +28 -0
- package/dist/tools/taskOutputs.d.ts +49 -0
- package/dist/tools/taskOutputs.js +136 -0
- package/dist/tools/taskTemplates.d.ts +15 -0
- package/dist/tools/taskTemplates.js +116 -0
- package/dist/tools/toolCatalog.d.ts +19 -0
- package/dist/tools/toolCatalog.js +71 -0
- package/dist/tools/waitForTask.d.ts +25 -0
- package/dist/tools/waitForTask.js +59 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +2 -0
- package/dist/watcherStatus.d.ts +20 -0
- package/dist/watcherStatus.js +77 -0
- package/docs/assets/patchwarden-chatgpt-demo.svg +30 -0
- package/docs/demo.md +58 -0
- package/docs/migration-from-safe-bifrost.md +31 -0
- package/docs/release-v0.3.0.md +43 -0
- package/docs/release-v0.4.0.md +74 -0
- package/examples/config.example.json +40 -0
- package/examples/openai-tunnel/README.md +48 -0
- package/examples/openai-tunnel/chatgpt-test-prompt.md +53 -0
- package/examples/openai-tunnel/tunnel-client.example.yaml +39 -0
- package/package.json +73 -0
- package/scripts/brand-check.js +44 -0
- package/scripts/doctor-smoke.js +52 -0
- package/scripts/get-patchwarden-health.ps1 +114 -0
- package/scripts/http-mcp-smoke.js +351 -0
- package/scripts/lifecycle-smoke.js +453 -0
- package/scripts/mcp-manifest-check.js +120 -0
- package/scripts/mcp-smoke.js +336 -0
- package/scripts/pack-clean.js +142 -0
- package/scripts/patchwarden-mcp-stdio.cmd +7 -0
- package/scripts/restart-patchwarden.ps1 +242 -0
- package/scripts/start-patchwarden-tunnel.ps1 +503 -0
- package/scripts/tunnel-supervisor-smoke.js +77 -0
- package/scripts/watcher-supervisor-smoke.js +195 -0
- package/src/config.ts +165 -0
- package/src/doctor.ts +438 -0
- package/src/errors.ts +26 -0
- package/src/httpServer.ts +277 -0
- package/src/index.ts +34 -0
- package/src/runner/changeCapture.ts +304 -0
- package/src/runner/cli.ts +34 -0
- package/src/runner/runTask.ts +935 -0
- package/src/runner/watch.ts +152 -0
- package/src/security/commandGuard.ts +97 -0
- package/src/security/contentRedaction.ts +85 -0
- package/src/security/pathGuard.ts +175 -0
- package/src/security/planGuard.ts +93 -0
- package/src/security/sensitiveGuard.ts +72 -0
- package/src/smoke-test.ts +1078 -0
- package/src/taskProgress.ts +41 -0
- package/src/taskRuntime.ts +37 -0
- package/src/tools/auditTask.ts +340 -0
- package/src/tools/cancelTask.ts +70 -0
- package/src/tools/createTask.ts +337 -0
- package/src/tools/getPlan.ts +48 -0
- package/src/tools/getTaskFile.ts +111 -0
- package/src/tools/getTaskProgress.ts +5 -0
- package/src/tools/getTaskStatus.ts +85 -0
- package/src/tools/getTaskStdoutTail.ts +61 -0
- package/src/tools/getTaskSummary.ts +307 -0
- package/src/tools/healthCheck.ts +234 -0
- package/src/tools/killTask.ts +5 -0
- package/src/tools/listAgents.ts +54 -0
- package/src/tools/listTasks.ts +139 -0
- package/src/tools/listWorkspace.ts +71 -0
- package/src/tools/readWorkspaceFile.ts +41 -0
- package/src/tools/registry.ts +666 -0
- package/src/tools/retryTask.ts +53 -0
- package/src/tools/savePlan.ts +46 -0
- package/src/tools/taskOutputs.ts +155 -0
- package/src/tools/taskTemplates.ts +162 -0
- package/src/tools/toolCatalog.ts +92 -0
- package/src/tools/waitForTask.ts +90 -0
- package/src/version.ts +2 -0
- package/src/watcherStatus.ts +102 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import {
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
mkdtempSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
rmSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
14
|
+
|
|
15
|
+
const tempRoot = mkdtempSync(join(tmpdir(), "patchwarden-lifecycle-"));
|
|
16
|
+
const workspaceRoot = join(tempRoot, "workspace");
|
|
17
|
+
const repoPath = join(workspaceRoot, "repo");
|
|
18
|
+
const plainRepoPath = join(workspaceRoot, "plain-repo");
|
|
19
|
+
const configPath = join(tempRoot, "patchwarden.config.json");
|
|
20
|
+
let passed = 0;
|
|
21
|
+
let failed = 0;
|
|
22
|
+
|
|
23
|
+
function ok(name) {
|
|
24
|
+
console.log(` ok - ${name}`);
|
|
25
|
+
passed++;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function fail(name, error) {
|
|
29
|
+
console.error(` not ok - ${name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
30
|
+
failed++;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function test(name, fn) {
|
|
34
|
+
try {
|
|
35
|
+
await fn();
|
|
36
|
+
ok(name);
|
|
37
|
+
} catch (error) {
|
|
38
|
+
fail(name, error);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function git(args) {
|
|
43
|
+
const result = spawnSync("git", args, { cwd: repoPath, encoding: "utf-8" });
|
|
44
|
+
if (result.status !== 0) throw new Error(`git ${args.join(" ")} failed: ${result.stderr}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function waitForRunning(getTaskStatus, taskId) {
|
|
48
|
+
for (let attempt = 0; attempt < 50; attempt++) {
|
|
49
|
+
const status = getTaskStatus(taskId);
|
|
50
|
+
if (status.status === "running") return status;
|
|
51
|
+
await sleep(100);
|
|
52
|
+
}
|
|
53
|
+
throw new Error(`Task ${taskId} did not enter running state`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
console.log("\n=== PatchWarden Lifecycle Smoke Tests ===\n");
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
mkdirSync(repoPath, { recursive: true });
|
|
60
|
+
mkdirSync(join(workspaceRoot, ".patchwarden"), { recursive: true });
|
|
61
|
+
writeFileSync(join(workspaceRoot, ".patchwarden", "watcher-heartbeat.json"), JSON.stringify({
|
|
62
|
+
status: "running",
|
|
63
|
+
pid: process.pid,
|
|
64
|
+
instance_id: "lifecycle-smoke-watcher",
|
|
65
|
+
launcher_pid: process.pid,
|
|
66
|
+
started_at: new Date().toISOString(),
|
|
67
|
+
last_heartbeat_at: new Date().toISOString(),
|
|
68
|
+
}), "utf-8");
|
|
69
|
+
writeFileSync(join(repoPath, "README.md"), "# Lifecycle fixture\n", "utf-8");
|
|
70
|
+
writeFileSync(join(repoPath, "main.js"), "console.log('fixture');\n", "utf-8");
|
|
71
|
+
writeFileSync(join(repoPath, "second.js"), "console.log('second');\n", "utf-8");
|
|
72
|
+
writeFileSync(join(repoPath, "delete-me.txt"), "delete fixture\n", "utf-8");
|
|
73
|
+
mkdirSync(plainRepoPath, { recursive: true });
|
|
74
|
+
writeFileSync(join(plainRepoPath, "README.md"), "# Non-Git fixture\n", "utf-8");
|
|
75
|
+
git(["init"]);
|
|
76
|
+
git(["add", "README.md", "main.js", "second.js", "delete-me.txt"]);
|
|
77
|
+
git(["-c", "user.name=PatchWarden Test", "-c", "user.email=test@example.invalid", "commit", "-m", "fixture"]);
|
|
78
|
+
|
|
79
|
+
writeFileSync(
|
|
80
|
+
configPath,
|
|
81
|
+
JSON.stringify({
|
|
82
|
+
workspaceRoot,
|
|
83
|
+
plansDir: ".patchwarden/plans",
|
|
84
|
+
tasksDir: ".patchwarden/tasks",
|
|
85
|
+
agents: {
|
|
86
|
+
writer: {
|
|
87
|
+
command: process.execPath,
|
|
88
|
+
args: [
|
|
89
|
+
"-e",
|
|
90
|
+
"const fs=require('fs');fs.appendFileSync('README.md','task change\\n');fs.writeFileSync('new-file.txt','new file\\n')",
|
|
91
|
+
],
|
|
92
|
+
},
|
|
93
|
+
slow: {
|
|
94
|
+
command: process.execPath,
|
|
95
|
+
args: ["-e", "setTimeout(()=>console.log('finished'),30000)"],
|
|
96
|
+
},
|
|
97
|
+
scopebreaker: {
|
|
98
|
+
command: process.execPath,
|
|
99
|
+
args: ["-e", "require('fs').writeFileSync('../outside-scope.txt','unexpected\\n')"],
|
|
100
|
+
},
|
|
101
|
+
noop: {
|
|
102
|
+
command: process.execPath,
|
|
103
|
+
args: ["-e", "console.log('no changes')"],
|
|
104
|
+
},
|
|
105
|
+
largewriter: {
|
|
106
|
+
command: process.execPath,
|
|
107
|
+
args: ["-e", "require('fs').writeFileSync('large.txt','x'.repeat(260000)+'\\n')"],
|
|
108
|
+
},
|
|
109
|
+
deleter: {
|
|
110
|
+
command: process.execPath,
|
|
111
|
+
args: ["-e", "require('fs').unlinkSync('delete-me.txt')"],
|
|
112
|
+
},
|
|
113
|
+
binarywriter: {
|
|
114
|
+
command: process.execPath,
|
|
115
|
+
args: ["-e", "require('fs').writeFileSync('fixture.bin',Buffer.from([0,1,2,3,255,0,10]))"],
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
allowedTestCommands: ["node --check main.js", "node --check second.js", "node --check missing.js"],
|
|
119
|
+
maxReadFileBytes: 200000,
|
|
120
|
+
defaultTaskTimeoutSeconds: 10,
|
|
121
|
+
maxTaskTimeoutSeconds: 60,
|
|
122
|
+
watcherStaleSeconds: 3600,
|
|
123
|
+
}, null, 2),
|
|
124
|
+
"utf-8"
|
|
125
|
+
);
|
|
126
|
+
process.env.PATCHWARDEN_CONFIG = configPath;
|
|
127
|
+
|
|
128
|
+
const { savePlan } = await import("../dist/tools/savePlan.js");
|
|
129
|
+
const { createTask } = await import("../dist/tools/createTask.js");
|
|
130
|
+
const { getTaskStatus } = await import("../dist/tools/getTaskStatus.js");
|
|
131
|
+
const { cancelTask } = await import("../dist/tools/cancelTask.js");
|
|
132
|
+
const { killTask } = await import("../dist/tools/killTask.js");
|
|
133
|
+
const { getDiff } = await import("../dist/tools/taskOutputs.js");
|
|
134
|
+
const { listAgents } = await import("../dist/tools/listAgents.js");
|
|
135
|
+
const { getTaskSummary } = await import("../dist/tools/getTaskSummary.js");
|
|
136
|
+
const { waitForTask } = await import("../dist/tools/waitForTask.js");
|
|
137
|
+
const { runTask } = await import("../dist/runner/runTask.js");
|
|
138
|
+
|
|
139
|
+
await test("list_agents reports configured executables", async () => {
|
|
140
|
+
const result = listAgents();
|
|
141
|
+
if (result.total !== 7 || result.agents.some((agent) => !agent.available)) {
|
|
142
|
+
throw new Error(`Unexpected agent availability: ${JSON.stringify(result)}`);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
await test("git diff captures tracked and untracked task changes", async () => {
|
|
147
|
+
writeFileSync(join(repoPath, "preexisting-user-file.txt"), "preexisting and untouched\n", "utf-8");
|
|
148
|
+
const plan = savePlan({ title: "Change capture", content: "Modify fixture files." });
|
|
149
|
+
const task = createTask({
|
|
150
|
+
plan_id: plan.plan_id,
|
|
151
|
+
agent: "writer",
|
|
152
|
+
repo_path: "repo",
|
|
153
|
+
verify_commands: ["node --check main.js", "node --check second.js"],
|
|
154
|
+
});
|
|
155
|
+
const result = await runTask(task.task_id);
|
|
156
|
+
if (result.status !== "done") throw new Error(`Writer task ended ${result.status}: ${result.error}`);
|
|
157
|
+
const status = getTaskStatus(task.task_id);
|
|
158
|
+
const changed = status.changed_files || [];
|
|
159
|
+
if (!changed.some((file) => file.path === "README.md" && file.change === "modified")) {
|
|
160
|
+
throw new Error(`README.md modification missing: ${JSON.stringify(changed)}`);
|
|
161
|
+
}
|
|
162
|
+
if (!changed.some((file) => file.path === "new-file.txt" && file.change === "added")) {
|
|
163
|
+
throw new Error(`new-file.txt addition missing: ${JSON.stringify(changed)}`);
|
|
164
|
+
}
|
|
165
|
+
const diff = getDiff(task.task_id);
|
|
166
|
+
if (diff.patch_mode !== "textual" || diff.unavailable_reason !== null) {
|
|
167
|
+
throw new Error(`Expected textual Git patch: ${JSON.stringify(diff)}`);
|
|
168
|
+
}
|
|
169
|
+
if (!diff.content.includes("README.md") || !diff.content.includes("new-file.txt")) {
|
|
170
|
+
throw new Error("git.diff did not include both tracked and untracked evidence");
|
|
171
|
+
}
|
|
172
|
+
if (diff.content.includes("preexisting-user-file.txt")) {
|
|
173
|
+
throw new Error("Task diff included an unchanged pre-existing user file");
|
|
174
|
+
}
|
|
175
|
+
if (!status.diff_available) throw new Error("diff_available should be true");
|
|
176
|
+
if (!diff.file_stats?.some((file) => file.path === "new-file.txt" && file.status === "added" && file.additions > 0)) {
|
|
177
|
+
throw new Error(`Missing added file stats: ${JSON.stringify(diff.file_stats)}`);
|
|
178
|
+
}
|
|
179
|
+
for (const artifact of ["result.json", "verify.json", "verify.log", "diff.patch", "file-stats.json"]) {
|
|
180
|
+
if (!existsSync(join(task.path, artifact))) throw new Error(`${artifact} missing`);
|
|
181
|
+
}
|
|
182
|
+
const summary = getTaskSummary(task.task_id);
|
|
183
|
+
if (summary.verify_status !== "passed" || summary.verify_commands.length !== 2 || summary.acceptance_status !== "ready_for_review") {
|
|
184
|
+
throw new Error(`Unexpected summary: ${JSON.stringify(summary)}`);
|
|
185
|
+
}
|
|
186
|
+
const structured = JSON.parse(readFileSync(join(task.path, "result.json"), "utf-8"));
|
|
187
|
+
if (structured.verify_status !== "passed" || !Array.isArray(structured.errors) || !Array.isArray(structured.commands_observed)) {
|
|
188
|
+
throw new Error(`result.json contract mismatch: ${JSON.stringify(structured)}`);
|
|
189
|
+
}
|
|
190
|
+
for (const artifact of ["result.json", "diff.patch", "git.diff", "test.log"]) {
|
|
191
|
+
rmSync(join(task.path, artifact), { force: true });
|
|
192
|
+
}
|
|
193
|
+
const degraded = getTaskSummary(task.task_id);
|
|
194
|
+
if (degraded.result_json_available || degraded.diff_available || degraded.test_log_available) {
|
|
195
|
+
throw new Error(`Missing artifacts were not exposed: ${JSON.stringify(degraded)}`);
|
|
196
|
+
}
|
|
197
|
+
if (!degraded.warnings.some((warning) => warning.includes("result.json is missing")) ||
|
|
198
|
+
!degraded.warnings.some((warning) => warning.includes("diff.patch is missing")) ||
|
|
199
|
+
!degraded.warnings.some((warning) => warning.includes("test.log is missing"))) {
|
|
200
|
+
throw new Error(`Missing artifact warnings incomplete: ${JSON.stringify(degraded.warnings)}`);
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
await test("inspect_only template fails when the agent changes repository files", async () => {
|
|
205
|
+
const task = createTask({
|
|
206
|
+
template: "inspect_only",
|
|
207
|
+
goal: "Inspect the fixture without modifying files",
|
|
208
|
+
agent: "writer",
|
|
209
|
+
repo_path: "repo",
|
|
210
|
+
});
|
|
211
|
+
const result = await runTask(task.task_id);
|
|
212
|
+
if (result.status !== "failed_policy_violation") {
|
|
213
|
+
throw new Error(`Expected failed_policy_violation: ${JSON.stringify(result)}`);
|
|
214
|
+
}
|
|
215
|
+
const summary = getTaskSummary(task.task_id);
|
|
216
|
+
if (summary.change_policy !== "no_changes" || summary.suggested_next_action !== "review_unexpected_changes") {
|
|
217
|
+
throw new Error(`Unexpected policy summary: ${JSON.stringify(summary)}`);
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
await test("legacy test_command becomes one independent verification command", async () => {
|
|
222
|
+
const plan = savePlan({ title: "Legacy verification", content: "No changes." });
|
|
223
|
+
const task = createTask({
|
|
224
|
+
plan_id: plan.plan_id,
|
|
225
|
+
agent: "noop",
|
|
226
|
+
repo_path: "repo",
|
|
227
|
+
test_command: "node --check main.js",
|
|
228
|
+
});
|
|
229
|
+
const result = await runTask(task.task_id);
|
|
230
|
+
const verify = JSON.parse(readFileSync(join(task.path, "verify.json"), "utf-8"));
|
|
231
|
+
if (result.status !== "done" || verify.status !== "passed" || verify.commands.length !== 1) {
|
|
232
|
+
throw new Error(`Legacy verification mismatch: ${JSON.stringify({ result, verify })}`);
|
|
233
|
+
}
|
|
234
|
+
if (verify.commands[0].cwd !== repoPath || !("stdout_tail" in verify.commands[0]) || !("stderr_tail" in verify.commands[0])) {
|
|
235
|
+
throw new Error(`Verification command evidence incomplete: ${JSON.stringify(verify.commands[0])}`);
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
await test("no-change task returns an explicit empty diff result", async () => {
|
|
240
|
+
const plan = savePlan({ title: "No diff", content: "Do not change files." });
|
|
241
|
+
const task = createTask({ plan_id: plan.plan_id, agent: "noop", repo_path: "repo" });
|
|
242
|
+
const result = await runTask(task.task_id);
|
|
243
|
+
if (result.status !== "done") throw new Error(`No-op task failed: ${JSON.stringify(result)}`);
|
|
244
|
+
const diff = getDiff(task.task_id);
|
|
245
|
+
const verify = JSON.parse(readFileSync(join(task.path, "verify.json"), "utf-8"));
|
|
246
|
+
if (verify.status !== "skipped") throw new Error(`Expected skipped verification: ${JSON.stringify(verify)}`);
|
|
247
|
+
if (diff.diff_available !== false || diff.changed_files?.length !== 0 || diff.message !== "No task file changes detected") {
|
|
248
|
+
throw new Error(`No-diff response unclear: ${JSON.stringify(diff)}`);
|
|
249
|
+
}
|
|
250
|
+
if (diff.patch_mode !== "no_changes" || diff.unavailable_reason !== null) {
|
|
251
|
+
throw new Error(`Expected explicit no_changes patch mode: ${JSON.stringify(diff)}`);
|
|
252
|
+
}
|
|
253
|
+
rmSync(join(task.path, "result.json"), { force: true });
|
|
254
|
+
const fallback = getTaskSummary(task.task_id);
|
|
255
|
+
if (!fallback.terminal || fallback.result_json_available || !fallback.summary) {
|
|
256
|
+
throw new Error(`Summary fallback failed: ${JSON.stringify(fallback)}`);
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
await test("large diff response is truncated while diff.patch remains complete", async () => {
|
|
261
|
+
const plan = savePlan({ title: "Large diff", content: "Create a large text file." });
|
|
262
|
+
const task = createTask({ plan_id: plan.plan_id, agent: "largewriter", repo_path: "repo" });
|
|
263
|
+
const result = await runTask(task.task_id);
|
|
264
|
+
if (result.status !== "done") throw new Error(`Large task failed: ${JSON.stringify(result)}`);
|
|
265
|
+
const diff = getDiff(task.task_id);
|
|
266
|
+
const patchSize = readFileSync(join(task.path, "diff.patch"), "utf-8").length;
|
|
267
|
+
if (!diff.truncated || !diff.patch_head || !diff.diff_patch_path || patchSize <= diff.content.length) {
|
|
268
|
+
throw new Error(`Large diff contract mismatch: ${JSON.stringify({ diff, patchSize })}`);
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
await test("non-Git repositories return hash-only evidence with a reason", async () => {
|
|
273
|
+
const plan = savePlan({ title: "Non-Git evidence", content: "Modify files in a non-Git repository." });
|
|
274
|
+
const task = createTask({ plan_id: plan.plan_id, agent: "writer", repo_path: "plain-repo" });
|
|
275
|
+
const result = await runTask(task.task_id);
|
|
276
|
+
if (result.status !== "done") throw new Error(`Non-Git task failed: ${JSON.stringify(result)}`);
|
|
277
|
+
const diff = getDiff(task.task_id);
|
|
278
|
+
if (
|
|
279
|
+
diff.patch_mode !== "hash_only" ||
|
|
280
|
+
!diff.unavailable_reason?.includes("not a Git worktree") ||
|
|
281
|
+
!diff.changed_files?.some((file) => file.path === "README.md")
|
|
282
|
+
) {
|
|
283
|
+
throw new Error(`Non-Git diff evidence mismatch: ${JSON.stringify(diff)}`);
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
await test("binary Git changes remain reviewable as a textual Git binary patch", async () => {
|
|
288
|
+
const plan = savePlan({ title: "Binary evidence", content: "Create a binary fixture." });
|
|
289
|
+
const task = createTask({ plan_id: plan.plan_id, agent: "binarywriter", repo_path: "repo" });
|
|
290
|
+
const result = await runTask(task.task_id);
|
|
291
|
+
if (result.status !== "done") throw new Error(`Binary task failed: ${JSON.stringify(result)}`);
|
|
292
|
+
const diff = getDiff(task.task_id);
|
|
293
|
+
if (diff.patch_mode !== "textual" || !diff.content.includes("GIT binary patch")) {
|
|
294
|
+
throw new Error(`Binary patch evidence mismatch: ${JSON.stringify(diff)}`);
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
await test("deleted tracked files are identified with file stats", async () => {
|
|
299
|
+
const plan = savePlan({ title: "Delete fixture", content: "Delete the designated fixture file." });
|
|
300
|
+
const task = createTask({ plan_id: plan.plan_id, agent: "deleter", repo_path: "repo" });
|
|
301
|
+
const result = await runTask(task.task_id);
|
|
302
|
+
if (result.status !== "done") throw new Error(`Delete task failed: ${JSON.stringify(result)}`);
|
|
303
|
+
const diff = getDiff(task.task_id);
|
|
304
|
+
if (!diff.file_stats?.some((file) => file.path === "delete-me.txt" && file.status === "deleted")) {
|
|
305
|
+
throw new Error(`Deleted file stats missing: ${JSON.stringify(diff.file_stats)}`);
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
await test("wait_for_task stays in the tool loop and returns terminal acceptance", async () => {
|
|
310
|
+
const plan = savePlan({ title: "Wait loop", content: "Finish normally." });
|
|
311
|
+
const task = createTask({ plan_id: plan.plan_id, agent: "writer", repo_path: "repo" });
|
|
312
|
+
const running = runTask(task.task_id);
|
|
313
|
+
const waited = await waitForTask(task.task_id, 5);
|
|
314
|
+
await running;
|
|
315
|
+
if (!waited.terminal || waited.continuation_required || !waited.summary) {
|
|
316
|
+
throw new Error(`Unexpected wait response: ${JSON.stringify(waited)}`);
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
await test("wait_for_task explicitly requires another call when the task is not terminal", async () => {
|
|
321
|
+
const plan = savePlan({ title: "Pending wait", content: "Remain queued for this check." });
|
|
322
|
+
const task = createTask({ plan_id: plan.plan_id, agent: "slow", repo_path: "repo" });
|
|
323
|
+
const waited = await waitForTask(task.task_id, 1);
|
|
324
|
+
if (waited.terminal || !waited.timed_out || !waited.continuation_required) {
|
|
325
|
+
throw new Error(`Expected continuation response: ${JSON.stringify(waited)}`);
|
|
326
|
+
}
|
|
327
|
+
cancelTask(task.task_id);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
await test("running task summary includes heartbeat, phase, command, and elapsed time", async () => {
|
|
331
|
+
const plan = savePlan({ title: "Running summary", content: "Wait." });
|
|
332
|
+
const task = createTask({ plan_id: plan.plan_id, agent: "slow", repo_path: "repo", timeout_seconds: 30 });
|
|
333
|
+
const running = runTask(task.task_id);
|
|
334
|
+
await waitForRunning(getTaskStatus, task.task_id);
|
|
335
|
+
await sleep(50);
|
|
336
|
+
const summary = getTaskSummary(task.task_id);
|
|
337
|
+
if (summary.terminal || !summary.last_heartbeat_at || !summary.phase || !summary.current_command || summary.elapsed_ms < 0) {
|
|
338
|
+
throw new Error(`Running summary incomplete: ${JSON.stringify(summary)}`);
|
|
339
|
+
}
|
|
340
|
+
cancelTask(task.task_id);
|
|
341
|
+
await running;
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
await test("verification failure produces failed_verification and structured evidence", async () => {
|
|
345
|
+
const plan = savePlan({ title: "Verify failure", content: "Finish normally." });
|
|
346
|
+
const task = createTask({
|
|
347
|
+
plan_id: plan.plan_id,
|
|
348
|
+
agent: "writer",
|
|
349
|
+
repo_path: "repo",
|
|
350
|
+
verify_commands: ["node --check missing.js"],
|
|
351
|
+
});
|
|
352
|
+
const result = await runTask(task.task_id);
|
|
353
|
+
if (result.status !== "failed_verification") throw new Error(`Unexpected result: ${JSON.stringify(result)}`);
|
|
354
|
+
const verify = JSON.parse(readFileSync(join(task.path, "verify.json"), "utf-8"));
|
|
355
|
+
if (verify.status !== "failed" || verify.commands?.[0]?.exit_code === 0) {
|
|
356
|
+
throw new Error(`Verification evidence mismatch: ${JSON.stringify(verify)}`);
|
|
357
|
+
}
|
|
358
|
+
const resultMd = readFileSync(join(task.path, "result.md"), "utf-8");
|
|
359
|
+
const resultJson = JSON.parse(readFileSync(join(task.path, "result.json"), "utf-8"));
|
|
360
|
+
if (!resultMd.includes("node --check missing.js") || !resultJson.summary.includes("node --check missing.js")) {
|
|
361
|
+
throw new Error("Failed verification command missing from result artifacts");
|
|
362
|
+
}
|
|
363
|
+
const summary = getTaskSummary(task.task_id);
|
|
364
|
+
if (
|
|
365
|
+
resultJson.failed_command !== "node --check missing.js" ||
|
|
366
|
+
resultJson.suggested_next_action !== "create_followup_task" ||
|
|
367
|
+
!resultJson.safe_followup_prompt?.includes("Do not change unrelated files") ||
|
|
368
|
+
summary.suggested_next_action !== "create_followup_task"
|
|
369
|
+
) {
|
|
370
|
+
throw new Error(`Failure follow-up evidence missing: ${JSON.stringify(resultJson)}`);
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
await test("out-of-scope workspace changes fail the task and generate a rollback plan", async () => {
|
|
375
|
+
const plan = savePlan({ title: "Scope violation", content: "Do not leave the repository." });
|
|
376
|
+
const task = createTask({
|
|
377
|
+
plan_id: plan.plan_id,
|
|
378
|
+
agent: "scopebreaker",
|
|
379
|
+
repo_path: "repo",
|
|
380
|
+
verify_commands: ["node --check main.js"],
|
|
381
|
+
});
|
|
382
|
+
const result = await runTask(task.task_id);
|
|
383
|
+
if (result.status !== "failed_scope_violation") throw new Error(`Unexpected result: ${JSON.stringify(result)}`);
|
|
384
|
+
const structured = JSON.parse(readFileSync(join(task.path, "result.json"), "utf-8"));
|
|
385
|
+
if (!structured.out_of_scope_changes?.some((file) => file.path === "outside-scope.txt")) {
|
|
386
|
+
throw new Error(`Missing scope evidence: ${JSON.stringify(structured.out_of_scope_changes)}`);
|
|
387
|
+
}
|
|
388
|
+
if (structured.verify_status !== "failed") throw new Error(`Scope violation verify status must fail: ${structured.verify_status}`);
|
|
389
|
+
const rollbackPath = join(task.path, "rollback_scope_violation_plan.md");
|
|
390
|
+
if (!existsSync(rollbackPath)) throw new Error("rollback_scope_violation_plan.md missing");
|
|
391
|
+
const rollback = readFileSync(rollbackPath, "utf-8");
|
|
392
|
+
if (!rollback.includes("outside-scope.txt") || rollback.includes("README.md") || rollback.includes("new-file.txt")) {
|
|
393
|
+
throw new Error(`Rollback plan contains wrong files: ${rollback}`);
|
|
394
|
+
}
|
|
395
|
+
const summary = getTaskSummary(task.task_id);
|
|
396
|
+
if (summary.verify_status !== "failed" || summary.out_of_scope_changes.length === 0 || summary.acceptance_status !== "failed") {
|
|
397
|
+
throw new Error(`Scope summary mismatch: ${JSON.stringify(summary)}`);
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
await test("timeout terminates a long-running agent", async () => {
|
|
402
|
+
const plan = savePlan({ title: "Timeout", content: "Wait." });
|
|
403
|
+
const task = createTask({ plan_id: plan.plan_id, agent: "slow", repo_path: "repo", timeout_seconds: 1 });
|
|
404
|
+
const started = Date.now();
|
|
405
|
+
const result = await runTask(task.task_id);
|
|
406
|
+
if (result.status !== "failed" || !result.error?.includes("timed out")) {
|
|
407
|
+
throw new Error(`Expected timeout failure, got ${JSON.stringify(result)}`);
|
|
408
|
+
}
|
|
409
|
+
if (Date.now() - started > 10000) throw new Error("Timeout took too long to stop the process");
|
|
410
|
+
const status = getTaskStatus(task.task_id);
|
|
411
|
+
if (status.phase !== "failed" || !status.last_heartbeat_at) {
|
|
412
|
+
throw new Error(`Missing timeout phase/heartbeat: ${JSON.stringify(status)}`);
|
|
413
|
+
}
|
|
414
|
+
if (!existsSync(join(task.path, "progress.md"))) throw new Error("progress.md missing");
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
await test("cancel_task safely stops a running agent", async () => {
|
|
418
|
+
const plan = savePlan({ title: "Cancel", content: "Wait." });
|
|
419
|
+
const task = createTask({ plan_id: plan.plan_id, agent: "slow", repo_path: "repo", timeout_seconds: 30 });
|
|
420
|
+
const running = runTask(task.task_id);
|
|
421
|
+
await waitForRunning(getTaskStatus, task.task_id);
|
|
422
|
+
const request = cancelTask(task.task_id);
|
|
423
|
+
if (!request.cancel_requested || request.force_kill_requested) {
|
|
424
|
+
throw new Error(`Unexpected cancel response: ${JSON.stringify(request)}`);
|
|
425
|
+
}
|
|
426
|
+
const result = await running;
|
|
427
|
+
if (result.status !== "canceled") throw new Error(`Expected canceled, got ${JSON.stringify(result)}`);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
await test("kill_task immediately stops a running agent", async () => {
|
|
431
|
+
const plan = savePlan({ title: "Kill", content: "Wait." });
|
|
432
|
+
const task = createTask({ plan_id: plan.plan_id, agent: "slow", repo_path: "repo", timeout_seconds: 30 });
|
|
433
|
+
const running = runTask(task.task_id);
|
|
434
|
+
await waitForRunning(getTaskStatus, task.task_id);
|
|
435
|
+
const request = killTask(task.task_id);
|
|
436
|
+
if (!request.force_kill_requested) throw new Error(`Unexpected kill response: ${JSON.stringify(request)}`);
|
|
437
|
+
const result = await running;
|
|
438
|
+
if (result.status !== "canceled" || !result.error?.includes("kill_task")) {
|
|
439
|
+
throw new Error(`Expected killed/canceled result, got ${JSON.stringify(result)}`);
|
|
440
|
+
}
|
|
441
|
+
});
|
|
442
|
+
} catch (error) {
|
|
443
|
+
fail("lifecycle smoke setup", error);
|
|
444
|
+
} finally {
|
|
445
|
+
try { rmSync(tempRoot, { recursive: true, force: true }); } catch {}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
console.log(`\n${"=".repeat(50)}`);
|
|
449
|
+
console.log(`${passed} passed, ${failed} failed, ${passed + failed} total`);
|
|
450
|
+
console.log(`${"=".repeat(50)}\n`);
|
|
451
|
+
|
|
452
|
+
if (failed > 0) process.exit(1);
|
|
453
|
+
console.log("ALL LIFECYCLE TESTS PASSED\n");
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
5
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
6
|
+
import { CHATGPT_CORE_TOOL_NAMES } from "../dist/tools/toolCatalog.js";
|
|
7
|
+
|
|
8
|
+
const root = resolve(fileURLToPath(new URL("..", import.meta.url)));
|
|
9
|
+
const jsonOnly = process.argv.includes("--json");
|
|
10
|
+
const expectedTools = [...CHATGPT_CORE_TOOL_NAMES];
|
|
11
|
+
|
|
12
|
+
const transport = process.platform === "win32"
|
|
13
|
+
? new StdioClientTransport({
|
|
14
|
+
command: "cmd.exe",
|
|
15
|
+
args: ["/d", "/c", resolve(root, "scripts", "patchwarden-mcp-stdio.cmd")],
|
|
16
|
+
cwd: root,
|
|
17
|
+
stderr: "pipe",
|
|
18
|
+
})
|
|
19
|
+
: new StdioClientTransport({
|
|
20
|
+
command: process.execPath,
|
|
21
|
+
args: [resolve(root, "dist", "index.js")],
|
|
22
|
+
cwd: root,
|
|
23
|
+
env: {
|
|
24
|
+
...process.env,
|
|
25
|
+
PATCHWARDEN_CONFIG: resolve(root, "patchwarden.config.json"),
|
|
26
|
+
PATCHWARDEN_TOOL_PROFILE: "chatgpt_core",
|
|
27
|
+
},
|
|
28
|
+
stderr: "pipe",
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const client = new Client(
|
|
32
|
+
{ name: "patchwarden-manifest-check", version: "0.4.0" },
|
|
33
|
+
{ capabilities: {} }
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
await client.connect(transport);
|
|
38
|
+
const listed = await client.listTools();
|
|
39
|
+
const names = listed.tools.map((tool) => tool.name);
|
|
40
|
+
if (JSON.stringify(names) !== JSON.stringify(expectedTools)) {
|
|
41
|
+
throw new Error(`chatgpt_core tool list mismatch. Expected ${expectedTools.join(", ")}; got ${names.join(", ")}`);
|
|
42
|
+
}
|
|
43
|
+
// Layer 2: tools/list must carry _meta with manifest hash for catalog sync
|
|
44
|
+
if (!listed._meta || listed._meta.tool_profile !== "chatgpt_core" || listed._meta.tool_count !== expectedTools.length) {
|
|
45
|
+
throw new Error(`tools/list _meta missing or wrong profile/count: ${JSON.stringify(listed._meta || null)}`);
|
|
46
|
+
}
|
|
47
|
+
if (typeof listed._meta.tool_manifest_sha256 !== "string" || listed._meta.tool_manifest_sha256.length !== 64) {
|
|
48
|
+
throw new Error(`tools/list _meta manifest hash invalid: ${JSON.stringify(listed._meta)}`);
|
|
49
|
+
}
|
|
50
|
+
if (typeof listed._meta.schema_epoch !== "string" || typeof listed._meta.server_version !== "string") {
|
|
51
|
+
throw new Error(`tools/list _meta missing schema_epoch/server_version: ${JSON.stringify(listed._meta)}`);
|
|
52
|
+
}
|
|
53
|
+
const createTask = listed.tools.find((tool) => tool.name === "create_task");
|
|
54
|
+
const createProperties = Object.keys(createTask?.inputSchema?.properties || {});
|
|
55
|
+
for (const requiredProperty of ["inline_plan", "verify_commands"]) {
|
|
56
|
+
if (!createProperties.includes(requiredProperty)) {
|
|
57
|
+
throw new Error(`create_task schema is missing ${requiredProperty}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const waitTool = listed.tools.find((tool) => tool.name === "wait_for_task");
|
|
61
|
+
const waitProperties = Object.keys(waitTool?.inputSchema?.properties || {});
|
|
62
|
+
if (!waitProperties.includes("timeout_seconds") || !waitProperties.includes("wait_seconds")) {
|
|
63
|
+
throw new Error("wait_for_task schema must expose timeout_seconds and wait_seconds");
|
|
64
|
+
}
|
|
65
|
+
const healthProperties = Object.keys(listed.tools.find((tool) => tool.name === "health_check")?.inputSchema?.properties || {});
|
|
66
|
+
if (!healthProperties.includes("detail")) throw new Error("health_check schema must expose detail");
|
|
67
|
+
const listTaskProperties = Object.keys(listed.tools.find((tool) => tool.name === "list_tasks")?.inputSchema?.properties || {});
|
|
68
|
+
if (!listTaskProperties.includes("repo_path") || !listTaskProperties.includes("active_only")) {
|
|
69
|
+
throw new Error("list_tasks schema must expose repo_path and active_only");
|
|
70
|
+
}
|
|
71
|
+
const healthResult = await client.callTool({ name: "health_check", arguments: {} });
|
|
72
|
+
if (healthResult.isError) throw new Error(String(healthResult.content?.[0]?.text || "health_check failed"));
|
|
73
|
+
const health = JSON.parse(String(healthResult.content?.[0]?.text || "{}"));
|
|
74
|
+
if (
|
|
75
|
+
health.tool_profile !== "chatgpt_core" ||
|
|
76
|
+
health.tool_count !== expectedTools.length ||
|
|
77
|
+
!health.tool_manifest_sha256
|
|
78
|
+
) {
|
|
79
|
+
throw new Error(`health_check catalog mismatch: ${JSON.stringify(health)}`);
|
|
80
|
+
}
|
|
81
|
+
const hiddenResult = await client.callTool({ name: "get_plan", arguments: { plan_id: "stale-client-probe" } });
|
|
82
|
+
const hiddenPayload = JSON.parse(String(hiddenResult.content?.[0]?.text || "{}"));
|
|
83
|
+
if (
|
|
84
|
+
!hiddenResult.isError ||
|
|
85
|
+
hiddenPayload.reason !== "tool_catalog_mismatch" ||
|
|
86
|
+
hiddenPayload.refresh_required !== true ||
|
|
87
|
+
hiddenPayload.tool_manifest_sha256 !== health.tool_manifest_sha256
|
|
88
|
+
) {
|
|
89
|
+
throw new Error(`hidden tool mismatch guidance is incomplete: ${JSON.stringify(hiddenPayload)}`);
|
|
90
|
+
}
|
|
91
|
+
// Layer 3: mismatch error must carry next_tool_call and connector_refresh_steps for self-healing
|
|
92
|
+
if (hiddenPayload.next_tool_call?.name !== "health_check" || hiddenPayload.next_tool_call?.arguments?.detail !== "self_diagnostic") {
|
|
93
|
+
throw new Error(`mismatch next_tool_call missing or wrong: ${JSON.stringify(hiddenPayload.next_tool_call || null)}`);
|
|
94
|
+
}
|
|
95
|
+
if (!Array.isArray(hiddenPayload.connector_refresh_steps) || hiddenPayload.connector_refresh_steps.length < 3) {
|
|
96
|
+
throw new Error(`mismatch connector_refresh_steps missing or too short: ${JSON.stringify(hiddenPayload.connector_refresh_steps || null)}`);
|
|
97
|
+
}
|
|
98
|
+
const output = {
|
|
99
|
+
ok: true,
|
|
100
|
+
server_version: health.server_version,
|
|
101
|
+
schema_epoch: health.schema_epoch,
|
|
102
|
+
tool_profile: health.tool_profile,
|
|
103
|
+
tool_count: health.tool_count,
|
|
104
|
+
tool_names: health.tool_names,
|
|
105
|
+
tool_manifest_sha256: health.tool_manifest_sha256,
|
|
106
|
+
required_schema: {
|
|
107
|
+
create_task: ["inline_plan", "verify_commands"],
|
|
108
|
+
wait_for_task: ["timeout_seconds", "wait_seconds"],
|
|
109
|
+
health_check: ["detail"],
|
|
110
|
+
list_tasks: ["repo_path", "active_only"],
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
console.log(jsonOnly ? JSON.stringify(output) : JSON.stringify(output, null, 2));
|
|
114
|
+
} catch (error) {
|
|
115
|
+
const failure = { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
116
|
+
console.error(jsonOnly ? JSON.stringify(failure) : JSON.stringify(failure, null, 2));
|
|
117
|
+
process.exitCode = 1;
|
|
118
|
+
} finally {
|
|
119
|
+
await client.close().catch(() => {});
|
|
120
|
+
}
|