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,277 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* PatchWarden MCP Server — HTTP (Streamable HTTP) transport
|
|
4
|
+
*
|
|
5
|
+
* Binds to 127.0.0.1 only. Never exposes to LAN or public internet.
|
|
6
|
+
* Use with OpenAI tunnel-client or ChatGPT Connector.
|
|
7
|
+
*
|
|
8
|
+
* Each HTTP request gets its own MCP Server + transport instance
|
|
9
|
+
* to avoid "Already connected" errors from reusing a single Server.
|
|
10
|
+
*
|
|
11
|
+
* Config options (in patchwarden.config.json):
|
|
12
|
+
* httpPort: number (default 7331)
|
|
13
|
+
*
|
|
14
|
+
* Run: node dist/httpServer.js
|
|
15
|
+
* or: npm run start:http
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { createServer, IncomingMessage, ServerResponse } from "node:http";
|
|
19
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { join, resolve } from "node:path";
|
|
21
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
22
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
23
|
+
import { loadConfig, getTasksDir } from "./config.js";
|
|
24
|
+
import { registerTools } from "./tools/registry.js";
|
|
25
|
+
import { healthCheck } from "./tools/healthCheck.js";
|
|
26
|
+
import { getToolCatalogSnapshot } from "./tools/registry.js";
|
|
27
|
+
import { PATCHWARDEN_VERSION } from "./version.js";
|
|
28
|
+
|
|
29
|
+
// ── Bootstrap ─────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
const config = loadConfig();
|
|
32
|
+
const port = parseInt(process.env.PATCHWARDEN_HTTP_PORT || "") ||
|
|
33
|
+
(config as any).httpPort ||
|
|
34
|
+
7331;
|
|
35
|
+
const host = "127.0.0.1";
|
|
36
|
+
|
|
37
|
+
console.error(`[patchwarden-http] Workspace: ${config.workspaceRoot}`);
|
|
38
|
+
console.error(`[patchwarden-http] Listening: http://${host}:${port}/mcp`);
|
|
39
|
+
console.error(`[patchwarden-http] ⚠️ Bound to 127.0.0.1 only — not exposed to network`);
|
|
40
|
+
|
|
41
|
+
// ── Owner token (optional) ────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
const httpCfg = (config as any).http || {};
|
|
44
|
+
const ownerTokenEnv = httpCfg.ownerTokenEnv || "PATCHWARDEN_OWNER_TOKEN";
|
|
45
|
+
const ownerToken = process.env[ownerTokenEnv] || "";
|
|
46
|
+
|
|
47
|
+
if (ownerToken) {
|
|
48
|
+
console.error(`[patchwarden-http] 🔒 Owner token required (env: ${ownerTokenEnv})`);
|
|
49
|
+
} else {
|
|
50
|
+
console.error(`[patchwarden-http] ⚠️ No owner token set — all local requests accepted`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function checkOwnerToken(req: IncomingMessage): boolean {
|
|
54
|
+
if (!ownerToken) return true; // no token configured — allow all
|
|
55
|
+
|
|
56
|
+
const authHeader = req.headers["authorization"] || "";
|
|
57
|
+
const customHeader = req.headers["x-patchwarden-token"] || "";
|
|
58
|
+
|
|
59
|
+
if (authHeader.startsWith("Bearer ")) {
|
|
60
|
+
return authHeader.slice(7) === ownerToken;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (typeof customHeader === "string" && customHeader.length > 0) {
|
|
64
|
+
return customHeader === ownerToken;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── Acceptance helpers ────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
function getAcceptancePath(taskId: string): string {
|
|
73
|
+
const tasksDir = getTasksDir(config);
|
|
74
|
+
return join(resolve(tasksDir, taskId), "acceptance.json");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function handleAcceptance(taskId: string, status: "accepted" | "rejected", body: string): object {
|
|
78
|
+
const filePath = getAcceptancePath(taskId);
|
|
79
|
+
const taskDir = join(resolve(getTasksDir(config), taskId));
|
|
80
|
+
if (!existsSync(taskDir)) {
|
|
81
|
+
throw Object.assign(new Error(`Task "${taskId}" not found.`), { statusCode: 404 });
|
|
82
|
+
}
|
|
83
|
+
let notes = "";
|
|
84
|
+
try {
|
|
85
|
+
const parsed = JSON.parse(body || "{}");
|
|
86
|
+
notes = String(parsed.notes || parsed.reason || "");
|
|
87
|
+
} catch {
|
|
88
|
+
// no body or invalid JSON — notes stays empty
|
|
89
|
+
}
|
|
90
|
+
const acceptance = {
|
|
91
|
+
status,
|
|
92
|
+
reviewed_at: new Date().toISOString(),
|
|
93
|
+
reviewer: "human",
|
|
94
|
+
notes,
|
|
95
|
+
};
|
|
96
|
+
writeFileSync(filePath, JSON.stringify(acceptance, null, 2), "utf-8");
|
|
97
|
+
|
|
98
|
+
// Also update the task's status.json with acceptance_status
|
|
99
|
+
const statusFile = join(taskDir, "status.json");
|
|
100
|
+
if (existsSync(statusFile)) {
|
|
101
|
+
try {
|
|
102
|
+
const current = JSON.parse(readFileSync(statusFile, "utf-8"));
|
|
103
|
+
current.acceptance_status = status;
|
|
104
|
+
current.acceptance_reviewed_at = acceptance.reviewed_at;
|
|
105
|
+
writeFileSync(statusFile, JSON.stringify(current, null, 2), "utf-8");
|
|
106
|
+
} catch {
|
|
107
|
+
// status.json update is best-effort
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return acceptance;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function readAcceptance(taskId: string): object {
|
|
114
|
+
const filePath = getAcceptancePath(taskId);
|
|
115
|
+
if (!existsSync(filePath)) {
|
|
116
|
+
return { status: "pending", reviewed_at: null, reviewer: null, notes: null };
|
|
117
|
+
}
|
|
118
|
+
return JSON.parse(readFileSync(filePath, "utf-8"));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── Helpers ───────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
/** Create a fresh MCP Server with tools registered */
|
|
124
|
+
function createMcpServer(): Server {
|
|
125
|
+
const server = new Server(
|
|
126
|
+
{ name: "patchwarden", version: PATCHWARDEN_VERSION },
|
|
127
|
+
{ capabilities: { tools: {} } }
|
|
128
|
+
);
|
|
129
|
+
registerTools(server);
|
|
130
|
+
return server;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Handle one MCP request with its own server+transport lifecycle */
|
|
134
|
+
async function handleMcpRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
135
|
+
// Fresh instances per request — no shared state, no "already connected" errors
|
|
136
|
+
const mcpServer = createMcpServer();
|
|
137
|
+
const transport = new StreamableHTTPServerTransport({
|
|
138
|
+
sessionIdGenerator: undefined, // stateless
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
await mcpServer.connect(transport);
|
|
143
|
+
await transport.handleRequest(req, res);
|
|
144
|
+
} catch (err) {
|
|
145
|
+
console.error("[patchwarden-http] Request error:", err);
|
|
146
|
+
if (!res.headersSent) {
|
|
147
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
148
|
+
res.end(JSON.stringify({ error: "Internal server error" }));
|
|
149
|
+
}
|
|
150
|
+
} finally {
|
|
151
|
+
// Always close to free resources
|
|
152
|
+
try {
|
|
153
|
+
await transport.close();
|
|
154
|
+
} catch {
|
|
155
|
+
// best effort
|
|
156
|
+
}
|
|
157
|
+
try {
|
|
158
|
+
await mcpServer.close();
|
|
159
|
+
} catch {
|
|
160
|
+
// best effort
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── Parse URL for admin routes ────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
function parseAdminUrl(url: string) {
|
|
168
|
+
// Match: /admin/tasks/:id/accept, /admin/tasks/:id/reject, /admin/tasks/:id/acceptance
|
|
169
|
+
const acceptMatch = url.match(/^\/admin\/tasks\/(task_\w+)\/accept$/);
|
|
170
|
+
if (acceptMatch) return { taskId: acceptMatch[1], action: "accept" as const };
|
|
171
|
+
const rejectMatch = url.match(/^\/admin\/tasks\/(task_\w+)\/reject$/);
|
|
172
|
+
if (rejectMatch) return { taskId: rejectMatch[1], action: "reject" as const };
|
|
173
|
+
const readMatch = url.match(/^\/admin\/tasks\/(task_\w+)\/acceptance$/);
|
|
174
|
+
if (readMatch) return { taskId: readMatch[1], action: "get_acceptance" as const };
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ── HTTP server ───────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
const httpServer = createServer(async (req: IncomingMessage, res: ServerResponse) => {
|
|
181
|
+
// Health check endpoints
|
|
182
|
+
if (req.method === "GET" && (req.url === "/healthz" || req.url === "/readyz")) {
|
|
183
|
+
const health = healthCheck(getToolCatalogSnapshot());
|
|
184
|
+
const ready = health.mcp_server.available && health.workspace_root.available && health.tasks_dir.available;
|
|
185
|
+
res.writeHead(req.url === "/readyz" && !ready ? 503 : 200, { "Content-Type": "application/json" });
|
|
186
|
+
res.end(JSON.stringify({ ...health, ready }));
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Admin acceptance endpoints
|
|
191
|
+
const admin = parseAdminUrl(req.url || "");
|
|
192
|
+
if (admin) {
|
|
193
|
+
if (!checkOwnerToken(req)) {
|
|
194
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
195
|
+
res.end(JSON.stringify({ error: "Unauthorized — invalid or missing owner token" }));
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
199
|
+
if (admin.action === "get_acceptance" && req.method === "GET") {
|
|
200
|
+
const acceptance = readAcceptance(admin.taskId);
|
|
201
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
202
|
+
res.end(JSON.stringify(acceptance, null, 2));
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if ((admin.action === "accept" || admin.action === "reject") && req.method === "POST") {
|
|
206
|
+
const chunks: Buffer[] = [];
|
|
207
|
+
for await (const chunk of req) { chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); }
|
|
208
|
+
const body = Buffer.concat(chunks).toString("utf-8");
|
|
209
|
+
const acceptance = handleAcceptance(admin.taskId, admin.action === "accept" ? "accepted" : "rejected", body);
|
|
210
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
211
|
+
res.end(JSON.stringify(acceptance, null, 2));
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
res.writeHead(405, { "Content-Type": "application/json" });
|
|
215
|
+
res.end(JSON.stringify({ error: "Method not allowed for this admin endpoint." }));
|
|
216
|
+
} catch (err: any) {
|
|
217
|
+
const statusCode = err.statusCode || 500;
|
|
218
|
+
res.writeHead(statusCode, { "Content-Type": "application/json" });
|
|
219
|
+
res.end(JSON.stringify({ error: err.message || "Internal server error" }));
|
|
220
|
+
}
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// MCP endpoint
|
|
225
|
+
if (req.url !== "/mcp" && req.url !== "/mcp/") {
|
|
226
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
227
|
+
res.end(JSON.stringify({
|
|
228
|
+
error_code: "mcp_endpoint_not_found",
|
|
229
|
+
error: "PatchWarden MCP endpoint not found.",
|
|
230
|
+
expected_path: "/mcp",
|
|
231
|
+
health_path: "/healthz",
|
|
232
|
+
admin_paths: {
|
|
233
|
+
accept: "POST /admin/tasks/:id/accept",
|
|
234
|
+
reject: "POST /admin/tasks/:id/reject",
|
|
235
|
+
get_acceptance: "GET /admin/tasks/:id/acceptance",
|
|
236
|
+
},
|
|
237
|
+
suggestion: "Use POST /mcp for MCP requests, GET /healthz for local diagnostics, or /admin/tasks/:id/accept for human review.",
|
|
238
|
+
}));
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Owner token check (if configured)
|
|
243
|
+
if (!checkOwnerToken(req)) {
|
|
244
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
245
|
+
res.end(JSON.stringify({ error: "Unauthorized — invalid or missing owner token" }));
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
await handleMcpRequest(req, res);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// ── Start ─────────────────────────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
httpServer.on("error", (err: NodeJS.ErrnoException) => {
|
|
255
|
+
if (err.code === "EADDRINUSE") {
|
|
256
|
+
console.error(`[patchwarden-http] Fatal: port ${port} is already in use on ${host}.`);
|
|
257
|
+
console.error("[patchwarden-http] Stop the other PatchWarden HTTP instance or change httpPort in patchwarden.config.json.");
|
|
258
|
+
} else {
|
|
259
|
+
console.error(`[patchwarden-http] Fatal: ${err.message}`);
|
|
260
|
+
}
|
|
261
|
+
process.exit(1);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
httpServer.listen(port, host, () => {
|
|
265
|
+
console.error(`[patchwarden-http] ✅ Ready`);
|
|
266
|
+
console.error(`[patchwarden-http] Admin: http://${host}:${port}/admin/tasks/:id/accept`);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// Graceful shutdown
|
|
270
|
+
process.on("SIGINT", () => {
|
|
271
|
+
console.error("[patchwarden-http] Shutting down...");
|
|
272
|
+
httpServer.close(() => process.exit(0));
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
process.on("SIGTERM", () => {
|
|
276
|
+
httpServer.close(() => process.exit(0));
|
|
277
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* PatchWarden MCP Server — stdio transport
|
|
4
|
+
*
|
|
5
|
+
* Run: node dist/index.js
|
|
6
|
+
* Used by OpenAI tunnel-client via `--mcp.command`:
|
|
7
|
+
* tunnel-client ... --mcp.command "node" --mcp.args "dist/index.js"
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
11
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
12
|
+
import { loadConfig } from "./config.js";
|
|
13
|
+
import { registerTools } from "./tools/registry.js";
|
|
14
|
+
import { PATCHWARDEN_VERSION } from "./version.js";
|
|
15
|
+
|
|
16
|
+
const config = loadConfig();
|
|
17
|
+
|
|
18
|
+
console.error(`[patchwarden] Workspace: ${config.workspaceRoot}`);
|
|
19
|
+
console.error(`[patchwarden] Transport: stdio`);
|
|
20
|
+
|
|
21
|
+
const server = new Server(
|
|
22
|
+
{ name: "patchwarden", version: PATCHWARDEN_VERSION },
|
|
23
|
+
{ capabilities: { tools: {} } }
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
registerTools(server);
|
|
27
|
+
|
|
28
|
+
const transport = new StdioServerTransport();
|
|
29
|
+
server.connect(transport).catch((err) => {
|
|
30
|
+
console.error("[patchwarden] Fatal:", err);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
console.error("[patchwarden] MCP server ready on stdio");
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
lstatSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
readdirSync,
|
|
7
|
+
statSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
} from "node:fs";
|
|
10
|
+
import { join, relative, resolve } from "node:path";
|
|
11
|
+
import { spawnSync } from "node:child_process";
|
|
12
|
+
import { isSensitivePath } from "../security/sensitiveGuard.js";
|
|
13
|
+
|
|
14
|
+
const MAX_HASH_BYTES = 5 * 1024 * 1024;
|
|
15
|
+
const MAX_SNAPSHOT_FILES = 5000;
|
|
16
|
+
const MAX_DIFF_BYTES = 20 * 1024 * 1024;
|
|
17
|
+
const SKIP_DIRECTORIES = new Set([".git", ".patchwarden", "node_modules"]);
|
|
18
|
+
|
|
19
|
+
export interface FileFingerprint {
|
|
20
|
+
size: number;
|
|
21
|
+
sha256: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface RepoSnapshot {
|
|
25
|
+
captured_at: string;
|
|
26
|
+
is_git: boolean;
|
|
27
|
+
head: string | null;
|
|
28
|
+
status: string;
|
|
29
|
+
workspace_dirty: boolean;
|
|
30
|
+
files: Record<string, FileFingerprint>;
|
|
31
|
+
warnings: string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ChangedFile {
|
|
35
|
+
path: string;
|
|
36
|
+
change: "added" | "modified" | "deleted" | "renamed";
|
|
37
|
+
old_path?: string;
|
|
38
|
+
before_sha256: string | null;
|
|
39
|
+
after_sha256: string | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ChangeArtifacts {
|
|
43
|
+
changed_files: ChangedFile[];
|
|
44
|
+
diff: string;
|
|
45
|
+
diff_available: boolean;
|
|
46
|
+
diff_truncated: boolean;
|
|
47
|
+
diff_size_bytes: number;
|
|
48
|
+
additions: number;
|
|
49
|
+
deletions: number;
|
|
50
|
+
file_stats: Array<{
|
|
51
|
+
path: string;
|
|
52
|
+
status: ChangedFile["change"];
|
|
53
|
+
additions: number;
|
|
54
|
+
deletions: number;
|
|
55
|
+
}>;
|
|
56
|
+
workspace_dirty_before: boolean;
|
|
57
|
+
workspace_dirty_after: boolean;
|
|
58
|
+
patch_mode: "textual" | "no_changes" | "hash_only";
|
|
59
|
+
unavailable_reason: string | null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function captureRepoSnapshot(repoPath: string): RepoSnapshot {
|
|
63
|
+
const warnings: string[] = [];
|
|
64
|
+
const isGit = runGit(repoPath, ["rev-parse", "--is-inside-work-tree"]).stdout.trim() === "true";
|
|
65
|
+
let head: string | null = null;
|
|
66
|
+
let status = "";
|
|
67
|
+
let paths: string[] = [];
|
|
68
|
+
|
|
69
|
+
if (isGit) {
|
|
70
|
+
const headResult = runGit(repoPath, ["rev-parse", "HEAD"]);
|
|
71
|
+
if (headResult.status === 0) head = headResult.stdout.trim() || null;
|
|
72
|
+
status = runGit(repoPath, ["status", "--porcelain=v1", "-uall"]).stdout.trimEnd();
|
|
73
|
+
const listed = runGit(repoPath, ["ls-files", "-co", "--exclude-standard", "-z"]);
|
|
74
|
+
if (listed.status === 0) {
|
|
75
|
+
paths = [...new Set([
|
|
76
|
+
...listed.stdout.split("\0").filter(Boolean),
|
|
77
|
+
...walkWorkspace(repoPath),
|
|
78
|
+
])];
|
|
79
|
+
} else {
|
|
80
|
+
warnings.push("git ls-files failed; using bounded filesystem scan");
|
|
81
|
+
paths = walkWorkspace(repoPath);
|
|
82
|
+
}
|
|
83
|
+
} else {
|
|
84
|
+
warnings.push("repository is not a Git worktree; diff will contain file-change evidence only");
|
|
85
|
+
paths = walkWorkspace(repoPath);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (paths.length > MAX_SNAPSHOT_FILES) {
|
|
89
|
+
warnings.push(`snapshot limited to ${MAX_SNAPSHOT_FILES} files`);
|
|
90
|
+
paths = paths.slice(0, MAX_SNAPSHOT_FILES);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const files: Record<string, FileFingerprint> = {};
|
|
94
|
+
for (const inputPath of paths.sort()) {
|
|
95
|
+
const normalized = inputPath.replace(/\\/g, "/");
|
|
96
|
+
if (!normalized || normalized.startsWith(".patchwarden/") || isSensitivePath(normalized)) continue;
|
|
97
|
+
const absolutePath = resolve(repoPath, inputPath);
|
|
98
|
+
try {
|
|
99
|
+
const stat = lstatSync(absolutePath);
|
|
100
|
+
if (!stat.isFile()) continue;
|
|
101
|
+
const sha256 = stat.size <= MAX_HASH_BYTES
|
|
102
|
+
? createHash("sha256").update(readFileSync(absolutePath)).digest("hex")
|
|
103
|
+
: `large-file:${stat.size}:${Math.trunc(stat.mtimeMs)}`;
|
|
104
|
+
files[normalized] = { size: stat.size, sha256 };
|
|
105
|
+
} catch {
|
|
106
|
+
warnings.push(`could not fingerprint: ${normalized}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
captured_at: new Date().toISOString(),
|
|
112
|
+
is_git: isGit,
|
|
113
|
+
head,
|
|
114
|
+
status,
|
|
115
|
+
workspace_dirty: status.trim().length > 0,
|
|
116
|
+
files,
|
|
117
|
+
warnings,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function writeSnapshot(taskDir: string, filename: string, snapshot: RepoSnapshot): void {
|
|
122
|
+
writeFileSync(join(taskDir, filename), JSON.stringify(snapshot, null, 2), "utf-8");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function buildChangeArtifacts(
|
|
126
|
+
repoPath: string,
|
|
127
|
+
before: RepoSnapshot,
|
|
128
|
+
after: RepoSnapshot
|
|
129
|
+
): ChangeArtifacts {
|
|
130
|
+
const changedFiles = compareSnapshots(before, after);
|
|
131
|
+
const sections: string[] = [];
|
|
132
|
+
const scopedPaths = [...new Set(changedFiles.flatMap((file) => file.old_path ? [file.old_path, file.path] : [file.path]))];
|
|
133
|
+
|
|
134
|
+
if (before.is_git && after.is_git && scopedPaths.length > 0) {
|
|
135
|
+
if (before.head && after.head && before.head !== after.head) {
|
|
136
|
+
const committed = runGit(repoPath, ["diff", "--no-color", "--binary", before.head, after.head, "--", ...scopedPaths]);
|
|
137
|
+
if (committed.stdout.trim()) sections.push("# Changes committed during task\n", committed.stdout.trimEnd());
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const base = after.head || "HEAD";
|
|
141
|
+
const working = runGit(repoPath, ["diff", "--no-color", "--binary", base, "--", ...scopedPaths]);
|
|
142
|
+
if (working.stdout.trim()) sections.push("# Staged and unstaged changes\n", working.stdout.trimEnd());
|
|
143
|
+
|
|
144
|
+
for (const file of changedFiles.filter((item) => item.change === "added").slice(0, 100)) {
|
|
145
|
+
const tracked = runGit(repoPath, ["ls-files", "--error-unmatch", "--", file.path]);
|
|
146
|
+
if (tracked.status === 0) continue;
|
|
147
|
+
const untracked = runGit(repoPath, ["diff", "--no-index", "--no-color", "--binary", "--", "/dev/null", file.path]);
|
|
148
|
+
if (untracked.stdout.trim()) sections.push("# Untracked file\n", untracked.stdout.trimEnd());
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const evidence = [
|
|
153
|
+
"# PatchWarden change evidence",
|
|
154
|
+
`# changed_files: ${changedFiles.length}`,
|
|
155
|
+
`# workspace_dirty_before: ${before.workspace_dirty}`,
|
|
156
|
+
`# workspace_dirty_after: ${after.workspace_dirty}`,
|
|
157
|
+
...changedFiles.map((file) => `# ${file.change}: ${file.path}`),
|
|
158
|
+
].join("\n");
|
|
159
|
+
const body = sections.join("\n\n");
|
|
160
|
+
const fullDiff = `${evidence}\n\n${body || (changedFiles.length ? "(textual patch unavailable; see changed-files.json for hash evidence)" : "(no task file changes detected)")}\n`;
|
|
161
|
+
const additions = fullDiff.split(/\r?\n/).filter((line) => line.startsWith("+") && !line.startsWith("+++")).length;
|
|
162
|
+
const deletions = fullDiff.split(/\r?\n/).filter((line) => line.startsWith("-") && !line.startsWith("---")).length;
|
|
163
|
+
const fileStats = buildFileStats(repoPath, before, after, changedFiles);
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
changed_files: changedFiles,
|
|
167
|
+
diff: fullDiff,
|
|
168
|
+
diff_available: changedFiles.length > 0,
|
|
169
|
+
diff_truncated: false,
|
|
170
|
+
diff_size_bytes: Buffer.byteLength(fullDiff, "utf-8"),
|
|
171
|
+
additions,
|
|
172
|
+
deletions,
|
|
173
|
+
file_stats: fileStats,
|
|
174
|
+
workspace_dirty_before: before.workspace_dirty,
|
|
175
|
+
workspace_dirty_after: after.workspace_dirty,
|
|
176
|
+
patch_mode: changedFiles.length === 0 ? "no_changes" : body ? "textual" : "hash_only",
|
|
177
|
+
unavailable_reason: changedFiles.length > 0 && !body
|
|
178
|
+
? (before.is_git && after.is_git
|
|
179
|
+
? "Git could not produce a textual patch for the changed files; hash evidence remains available."
|
|
180
|
+
: "Repository is not a Git worktree; only bounded hash evidence is available.")
|
|
181
|
+
: null,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function buildFileStats(
|
|
186
|
+
repoPath: string,
|
|
187
|
+
before: RepoSnapshot,
|
|
188
|
+
after: RepoSnapshot,
|
|
189
|
+
changedFiles: ChangedFile[]
|
|
190
|
+
): ChangeArtifacts["file_stats"] {
|
|
191
|
+
return changedFiles.map((file) => {
|
|
192
|
+
let additions = 0;
|
|
193
|
+
let deletions = 0;
|
|
194
|
+
const paths = file.old_path ? [file.old_path, file.path] : [file.path];
|
|
195
|
+
|
|
196
|
+
if (before.is_git && after.is_git) {
|
|
197
|
+
const ranges: string[][] = [];
|
|
198
|
+
if (before.head && after.head && before.head !== after.head) {
|
|
199
|
+
ranges.push([before.head, after.head]);
|
|
200
|
+
}
|
|
201
|
+
ranges.push([after.head || "HEAD"]);
|
|
202
|
+
for (const range of ranges) {
|
|
203
|
+
const result = runGit(repoPath, ["diff", "--numstat", ...range, "--", ...paths]);
|
|
204
|
+
for (const line of result.stdout.split(/\r?\n/).filter(Boolean)) {
|
|
205
|
+
const [added, removed] = line.split(/\s+/);
|
|
206
|
+
if (/^\d+$/.test(added)) additions += Number(added);
|
|
207
|
+
if (/^\d+$/.test(removed)) deletions += Number(removed);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (file.change === "added" && additions === 0) {
|
|
213
|
+
try {
|
|
214
|
+
const content = readFileSync(resolve(repoPath, file.path), "utf-8");
|
|
215
|
+
additions = countLines(content);
|
|
216
|
+
} catch {}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return { path: file.path, status: file.change, additions, deletions };
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function countLines(content: string): number {
|
|
224
|
+
if (content.length === 0) return 0;
|
|
225
|
+
return content.split(/\r?\n/).length - (content.endsWith("\n") ? 1 : 0);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function compareSnapshots(before: RepoSnapshot, after: RepoSnapshot): ChangedFile[] {
|
|
229
|
+
const paths = [...new Set([...Object.keys(before.files), ...Object.keys(after.files)])].sort();
|
|
230
|
+
const changed: ChangedFile[] = [];
|
|
231
|
+
for (const path of paths) {
|
|
232
|
+
const left = before.files[path];
|
|
233
|
+
const right = after.files[path];
|
|
234
|
+
if (!left && right) {
|
|
235
|
+
changed.push({ path, change: "added", before_sha256: null, after_sha256: right.sha256 });
|
|
236
|
+
} else if (left && !right) {
|
|
237
|
+
changed.push({ path, change: "deleted", before_sha256: left.sha256, after_sha256: null });
|
|
238
|
+
} else if (left.sha256 !== right.sha256) {
|
|
239
|
+
changed.push({ path, change: "modified", before_sha256: left.sha256, after_sha256: right.sha256 });
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
const deletedByHash = new Map<string, ChangedFile[]>();
|
|
243
|
+
for (const file of changed.filter((item) => item.change === "deleted" && item.before_sha256)) {
|
|
244
|
+
const entries = deletedByHash.get(file.before_sha256!) || [];
|
|
245
|
+
entries.push(file);
|
|
246
|
+
deletedByHash.set(file.before_sha256!, entries);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const consumed = new Set<ChangedFile>();
|
|
250
|
+
const renamed: ChangedFile[] = [];
|
|
251
|
+
for (const file of changed.filter((item) => item.change === "added" && item.after_sha256)) {
|
|
252
|
+
const candidates = deletedByHash.get(file.after_sha256!) || [];
|
|
253
|
+
const source = candidates.find((item) => !consumed.has(item));
|
|
254
|
+
if (!source) continue;
|
|
255
|
+
consumed.add(source);
|
|
256
|
+
consumed.add(file);
|
|
257
|
+
renamed.push({
|
|
258
|
+
path: file.path,
|
|
259
|
+
old_path: source.path,
|
|
260
|
+
change: "renamed",
|
|
261
|
+
before_sha256: source.before_sha256,
|
|
262
|
+
after_sha256: file.after_sha256,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return [...changed.filter((item) => !consumed.has(item)), ...renamed]
|
|
267
|
+
.sort((left, right) => left.path.localeCompare(right.path));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function walkWorkspace(root: string): string[] {
|
|
271
|
+
const result: string[] = [];
|
|
272
|
+
const visit = (directory: string) => {
|
|
273
|
+
if (result.length >= MAX_SNAPSHOT_FILES) return;
|
|
274
|
+
let entries;
|
|
275
|
+
try {
|
|
276
|
+
entries = readdirSync(directory, { withFileTypes: true });
|
|
277
|
+
} catch {
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
for (const entry of entries) {
|
|
281
|
+
if (result.length >= MAX_SNAPSHOT_FILES) break;
|
|
282
|
+
if (entry.isDirectory() && SKIP_DIRECTORIES.has(entry.name)) continue;
|
|
283
|
+
const absolute = join(directory, entry.name);
|
|
284
|
+
if (entry.isDirectory()) visit(absolute);
|
|
285
|
+
else if (entry.isFile()) result.push(relative(root, absolute).replace(/\\/g, "/"));
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
visit(root);
|
|
289
|
+
return result;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function runGit(repoPath: string, args: string[]) {
|
|
293
|
+
const result = spawnSync("git", args, {
|
|
294
|
+
cwd: repoPath,
|
|
295
|
+
encoding: "utf-8",
|
|
296
|
+
timeout: 30_000,
|
|
297
|
+
maxBuffer: MAX_DIFF_BYTES,
|
|
298
|
+
});
|
|
299
|
+
return {
|
|
300
|
+
status: result.status,
|
|
301
|
+
stdout: result.stdout || "",
|
|
302
|
+
stderr: result.stderr || "",
|
|
303
|
+
};
|
|
304
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* PatchWarden Runner CLI
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* node dist/runner/cli.js <task_id>
|
|
7
|
+
* npm run runner -- <task_id>
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { runTask } from "./runTask.js";
|
|
11
|
+
import { loadConfig } from "../config.js";
|
|
12
|
+
|
|
13
|
+
// Load config early
|
|
14
|
+
loadConfig();
|
|
15
|
+
|
|
16
|
+
const taskId = process.argv[2] || process.env.PATCHWARDEN_TASK_ID;
|
|
17
|
+
|
|
18
|
+
if (!taskId) {
|
|
19
|
+
console.error("Usage: node dist/runner/cli.js <task_id>");
|
|
20
|
+
console.error(" or: npm run runner -- <task_id>");
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
console.error(`[runner] Starting task: ${taskId}`);
|
|
25
|
+
|
|
26
|
+
const result = await runTask(taskId);
|
|
27
|
+
|
|
28
|
+
console.error(`[runner] Task ${result.task_id}: ${result.status}`);
|
|
29
|
+
if (result.error) {
|
|
30
|
+
console.error(`[runner] Error: ${result.error}`);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
console.error("[runner] Done.");
|