@programinglive/dev-workflow-mcp-server 1.12.1 → 1.13.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/.dockerignore +24 -0
- package/.env.example +11 -31
- package/README.md +109 -6
- package/db/adapters/base.js +12 -0
- package/db/adapters/mysql.js +11 -5
- package/db/adapters/postgres.js +10 -5
- package/db/adapters/sqlite.js +15 -7
- package/db/index.js +2 -1
- package/docker-compose.yml +7 -5
- package/exec.js +78 -12
- package/factory-client.js +142 -10
- package/index.js +28 -19
- package/install.js +6 -5
- package/package.json +4 -1
- package/release-wrapper.js +32 -19
- package/scripts/check-review-guard.mjs +33 -0
- package/scripts/create-admin.js +11 -9
- package/scripts/install-husky.js +3 -2
- package/scripts/lint.mjs +20 -0
- package/scripts/release.cjs +25 -22
- package/scripts/release.js +6 -5
- package/scripts/setup-stack.js +42 -41
- package/scripts/update-release-notes.cjs +13 -12
- package/scripts/update-release-notes.js +8 -7
- package/scripts/verify-bundle.mjs +8 -7
- package/security/audit.js +86 -0
- package/security/commands.js +10 -0
- package/security/config.js +24 -0
- package/security/files.js +27 -0
- package/security/http.js +26 -0
- package/security/log-bridge.cjs +17 -0
- package/security/policy.js +37 -0
- package/security/references.js +21 -0
- package/security/runtime-secrets.js +42 -0
- package/security/sanitizer.js +42 -0
- package/security/scanner.js +108 -0
- package/tools/definitions.js +51 -3
- package/tools/handler-modules/commit-handlers.js +3 -7
- package/tools/handler-modules/factory-handlers.js +84 -0
- package/tools/handler-modules/index.js +45 -3
- package/tools/handler-modules/shared.js +5 -2
- package/tools/handler-modules/status-handlers.js +1 -1
- package/tools/handler-modules/summary-handlers.js +2 -1
- package/tools/handler-modules/workflow-handlers.js +2 -0
- package/utils.js +1 -0
- package/verify-build.js +7 -6
- package/vite.config.js +4 -0
- package/workflow-state.js +109 -18
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
findTask,
|
|
7
7
|
claimTask,
|
|
8
8
|
updateTaskStatus,
|
|
9
|
+
updateTaskDetails,
|
|
9
10
|
createTask,
|
|
10
11
|
listProjects,
|
|
11
12
|
formatTaskList,
|
|
@@ -149,6 +150,7 @@ export async function handleClaimFactoryTask(args = {}, { workflowState } = {})
|
|
|
149
150
|
: "",
|
|
150
151
|
`Factory status is now running (work session ${session.id}).`,
|
|
151
152
|
task.projectName ? `Project: ${task.projectName}` : "",
|
|
153
|
+
task.definitionOfDone ? `Definition of done:\n${task.definitionOfDone}` : "",
|
|
152
154
|
"",
|
|
153
155
|
"Local workflow started for this task.",
|
|
154
156
|
"Use 'sync_task_status' with review, blocked or paused to close the session, or done to approve the task once it is in review.",
|
|
@@ -199,10 +201,23 @@ export async function handleSyncTaskStatus(args = {}) {
|
|
|
199
201
|
// ditolak server dan hanya mengaburkan keadaan papan.
|
|
200
202
|
stopKeeping();
|
|
201
203
|
|
|
204
|
+
// Checklist dibaca lagi setelah status berubah: DoD bisa saja dilengkapi
|
|
205
|
+
// sejak claim, dan agen perlu kriteria terbaru saat melaporkan selesai.
|
|
206
|
+
// Kegagalan bacaan tambahan ini tidak boleh membuat laporan status yang
|
|
207
|
+
// sudah berhasil terlihat gagal.
|
|
208
|
+
let definitionOfDone = "";
|
|
209
|
+
try {
|
|
210
|
+
const task = await findTask(config, recordId);
|
|
211
|
+
definitionOfDone = String(task?.definitionOfDone || "").trim();
|
|
212
|
+
} catch {
|
|
213
|
+
definitionOfDone = "";
|
|
214
|
+
}
|
|
215
|
+
|
|
202
216
|
return textResponse(
|
|
203
217
|
[
|
|
204
218
|
`Updated ${recordId} to ${result.status}.`,
|
|
205
219
|
result.session?.summary ? `Summary: ${result.session.summary}` : "",
|
|
220
|
+
definitionOfDone ? `Definition of done:\n${definitionOfDone}` : "",
|
|
206
221
|
result.status === "review" ? "Waiting for human approval in Factory." : "",
|
|
207
222
|
]
|
|
208
223
|
.filter(Boolean)
|
|
@@ -241,6 +256,7 @@ export async function handleCreateFactoryTask(args = {}) {
|
|
|
241
256
|
|
|
242
257
|
const title = typeof args.title === "string" ? args.title.trim() : "";
|
|
243
258
|
const acceptance = typeof args.acceptance === "string" ? args.acceptance.trim() : "";
|
|
259
|
+
const definitionOfDone = typeof args.definitionOfDone === "string" ? args.definitionOfDone.trim() : "";
|
|
244
260
|
|
|
245
261
|
if (!title) {
|
|
246
262
|
return textResponse("Provide 'title' for the task (3-140 characters).");
|
|
@@ -253,6 +269,17 @@ export async function handleCreateFactoryTask(args = {}) {
|
|
|
253
269
|
].join("\n"),
|
|
254
270
|
);
|
|
255
271
|
}
|
|
272
|
+
if (!definitionOfDone) {
|
|
273
|
+
return textResponse(
|
|
274
|
+
[
|
|
275
|
+
"Provide 'definitionOfDone': the checklist that must be true before this task can be marked done.",
|
|
276
|
+
"Use one condition per line, for example:",
|
|
277
|
+
"- [ ] build lulus",
|
|
278
|
+
"- [ ] halaman login terverifikasi",
|
|
279
|
+
"It is stored separately from 'acceptance', which also carries the ```run block for the worker.",
|
|
280
|
+
].join("\n"),
|
|
281
|
+
);
|
|
282
|
+
}
|
|
256
283
|
if (!args.projectId) {
|
|
257
284
|
return textResponse(
|
|
258
285
|
"Provide 'projectId'. Run 'list_factory_projects' first - Factory needs a real project UUID.",
|
|
@@ -265,6 +292,7 @@ export async function handleCreateFactoryTask(args = {}) {
|
|
|
265
292
|
title,
|
|
266
293
|
description: args.description,
|
|
267
294
|
acceptance,
|
|
295
|
+
definitionOfDone,
|
|
268
296
|
priority: args.priority,
|
|
269
297
|
status: args.status,
|
|
270
298
|
});
|
|
@@ -286,3 +314,59 @@ export async function handleCreateFactoryTask(args = {}) {
|
|
|
286
314
|
return textResponse(`Could not create the task: ${error.message}`);
|
|
287
315
|
}
|
|
288
316
|
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Edits an existing task, mainly to fill the definition of done of a task that
|
|
320
|
+
* was created before Factory required it.
|
|
321
|
+
*
|
|
322
|
+
* Local validation runs first so an empty request never reaches the network.
|
|
323
|
+
*/
|
|
324
|
+
export async function handleUpdateFactoryTask(args = {}) {
|
|
325
|
+
const { config, response } = requireConfig();
|
|
326
|
+
if (!config) return response;
|
|
327
|
+
|
|
328
|
+
const recordId = typeof args.recordId === "string" ? args.recordId.trim() : "";
|
|
329
|
+
if (!recordId) {
|
|
330
|
+
return textResponse("Provide the 'recordId' of the task to update (see list_factory_tasks).");
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const hasField = ["title", "description", "acceptance", "definitionOfDone"].some(
|
|
334
|
+
(field) => typeof args[field] === "string",
|
|
335
|
+
);
|
|
336
|
+
if (!hasField) {
|
|
337
|
+
return textResponse(
|
|
338
|
+
[
|
|
339
|
+
"Provide at least one field to update: title, description, acceptance or definitionOfDone.",
|
|
340
|
+
"To fill a missing definition of done, pass definitionOfDone as a checklist, one condition per line.",
|
|
341
|
+
].join("\n"),
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
try {
|
|
346
|
+
const result = await updateTaskDetails(config, {
|
|
347
|
+
recordId,
|
|
348
|
+
title: args.title,
|
|
349
|
+
description: args.description,
|
|
350
|
+
acceptance: args.acceptance,
|
|
351
|
+
definitionOfDone: args.definitionOfDone,
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
return textResponse(
|
|
355
|
+
[
|
|
356
|
+
`Updated ${recordId}.`,
|
|
357
|
+
result.updatedAt ? `Task version is now ${result.updatedAt}.` : "",
|
|
358
|
+
]
|
|
359
|
+
.filter(Boolean)
|
|
360
|
+
.join("\n"),
|
|
361
|
+
);
|
|
362
|
+
} catch (error) {
|
|
363
|
+
// Factory menjawab 409 kalau task berubah sejak dibaca. Menyebut ulang
|
|
364
|
+
// langkah yang harus diambil lebih berguna daripada kode HTTP saja.
|
|
365
|
+
if (error?.status === 409) {
|
|
366
|
+
return textResponse(
|
|
367
|
+
`Could not update the task: ${error.message}\nReload the task with list_factory_tasks and try again with the current version.`,
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
return textResponse(`Could not update the task: ${error.message}`);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import { textResponse, isReleaseSatisfied, isErrorResponse } from "./shared.js";
|
|
2
|
+
import { assertSafeKnowledge } from '../../security/policy.js';
|
|
3
|
+
import { safeLog, securityOutputFilter } from '../../security/sanitizer.js';
|
|
4
|
+
import { securityAudit } from '../../security/audit.js';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { assertSafeToolCommand } from '../../security/commands.js';
|
|
7
|
+
import { sessionKeyFromRequest } from "../../workflow-state.js";
|
|
2
8
|
import { handleStartTask, handleMarkBugFixed, handleCreateTests, handleSkipTests, handleRunTests, handleCreateDocumentation, handleCreateFeatureFlow } from "./workflow-handlers.js";
|
|
3
9
|
import { handleSkipRelease, handlePerformRelease } from "./release-handlers.js";
|
|
4
10
|
import { handleCommitAndPush, handleCompleteTask, handleForceCompleteTask, handleDropTask } from "./commit-handlers.js";
|
|
5
11
|
import { handleProjectSummary, handleProjectSummaryData, handleProjectSummaryDb, handleViewHistory } from "./summary-handlers.js";
|
|
6
12
|
import { handleGetWorkflowStatus, handleReadyCheck, handleContinueWorkflow, handleRerunWorkflow } from "./status-handlers.js";
|
|
7
|
-
import { handleListFactoryTasks, handleClaimFactoryTask, handleSyncTaskStatus, handleListFactoryProjects, handleCreateFactoryTask } from "./factory-handlers.js";
|
|
13
|
+
import { handleListFactoryTasks, handleClaimFactoryTask, handleSyncTaskStatus, handleListFactoryProjects, handleCreateFactoryTask, handleUpdateFactoryTask } from "./factory-handlers.js";
|
|
8
14
|
|
|
9
15
|
function summarizeResponse(label, response) {
|
|
10
16
|
const text = response?.content?.[0]?.text || "";
|
|
@@ -156,7 +162,7 @@ async function handleRunFullWorkflow(args, { workflowState, exec, git, utils })
|
|
|
156
162
|
return textResponse(message);
|
|
157
163
|
}
|
|
158
164
|
|
|
159
|
-
|
|
165
|
+
async function dispatchToolCall({
|
|
160
166
|
request,
|
|
161
167
|
normalizeRequestArgs,
|
|
162
168
|
workflowState,
|
|
@@ -166,6 +172,14 @@ export async function handleToolCall({
|
|
|
166
172
|
}) {
|
|
167
173
|
try {
|
|
168
174
|
const { name } = request.params;
|
|
175
|
+
|
|
176
|
+
// Identitas sesi datang dari request metadata, bukan dari environment.
|
|
177
|
+
// Tanpa ini, satu proses MCP yang dipakai bergantian oleh beberapa sesi
|
|
178
|
+
// akan berbagi satu state dan saling menimpa task.
|
|
179
|
+
const sessionId = sessionKeyFromRequest(request);
|
|
180
|
+
if (sessionId && workflowState?.useSession) {
|
|
181
|
+
await workflowState.useSession(sessionId);
|
|
182
|
+
}
|
|
169
183
|
const { args, error: argumentsError } = normalizeRequestArgs(request.params.arguments);
|
|
170
184
|
|
|
171
185
|
if (argumentsError) {
|
|
@@ -173,6 +187,14 @@ export async function handleToolCall({
|
|
|
173
187
|
}
|
|
174
188
|
|
|
175
189
|
switch (name) {
|
|
190
|
+
case 'security_audit': {
|
|
191
|
+
const file=workflowState?.stateFile;
|
|
192
|
+
const marker=file?.replaceAll('\\','/').lastIndexOf('/.state/');
|
|
193
|
+
const root=marker>=0 ? file.slice(0,marker) : file ? path.dirname(file) : process.cwd();
|
|
194
|
+
const userId=process.env.DEV_WORKFLOW_USER_ID || (marker>=0?path.basename(path.dirname(file)):'default');
|
|
195
|
+
const report=await securityAudit({root,state:workflowState?.state,stateFile:file,userId});
|
|
196
|
+
return textResponse(JSON.stringify(report));
|
|
197
|
+
}
|
|
176
198
|
case "start_task":
|
|
177
199
|
return handleStartTask(args, workflowState);
|
|
178
200
|
case "mark_bug_fixed":
|
|
@@ -223,6 +245,8 @@ export async function handleToolCall({
|
|
|
223
245
|
return handleListFactoryProjects(args);
|
|
224
246
|
case "create_factory_task":
|
|
225
247
|
return handleCreateFactoryTask(args);
|
|
248
|
+
case "update_factory_task":
|
|
249
|
+
return handleUpdateFactoryTask(args);
|
|
226
250
|
case "claim_factory_task":
|
|
227
251
|
return handleClaimFactoryTask(args, { workflowState });
|
|
228
252
|
case "sync_task_status":
|
|
@@ -231,9 +255,27 @@ export async function handleToolCall({
|
|
|
231
255
|
throw new Error(`Unknown tool: ${name}`);
|
|
232
256
|
}
|
|
233
257
|
} catch (error) {
|
|
234
|
-
|
|
258
|
+
safeLog('error', "Tool handler error:", error);
|
|
235
259
|
return textResponse(
|
|
236
260
|
"⚠️ Internal server error while processing the tool request. Please try again."
|
|
237
261
|
);
|
|
238
262
|
}
|
|
239
263
|
}
|
|
264
|
+
|
|
265
|
+
// Await every handler here so asynchronous rejections cannot bypass egress checks.
|
|
266
|
+
export async function handleToolCall(context) {
|
|
267
|
+
try {
|
|
268
|
+
const {args,error}=context.normalizeRequestArgs(context.request.params.arguments);
|
|
269
|
+
if(error) return securityOutputFilter(textResponse(error));
|
|
270
|
+
assertSafeKnowledge({tool:context.request.params.name,args});
|
|
271
|
+
assertSafeToolCommand(context.request.params.name,args);
|
|
272
|
+
const response=await dispatchToolCall({...context,request:{...context.request,params:{...context.request.params,arguments:args}}});
|
|
273
|
+
return securityOutputFilter(response);
|
|
274
|
+
} catch(error) {
|
|
275
|
+
if(['SECRET_CONTENT_DENIED','UNSAFE_COMMAND_DENIED'].includes(error.code)) {
|
|
276
|
+
return {isError:true,content:[{type:'text',text:JSON.stringify({error:error.code,message:error.message,findings:error.findings})}]};
|
|
277
|
+
}
|
|
278
|
+
safeLog('error','Tool request failed',error);
|
|
279
|
+
return {isError:true,content:[{type:'text',text:'⚠️ Internal server error while processing the tool request.'}]};
|
|
280
|
+
}
|
|
281
|
+
}
|
|
@@ -2,6 +2,7 @@ import fs from "fs/promises";
|
|
|
2
2
|
import { existsSync } from "fs";
|
|
3
3
|
import path from "path";
|
|
4
4
|
import { fileURLToPath } from "url";
|
|
5
|
+
import { sanitize } from '../../security/sanitizer.js';
|
|
5
6
|
|
|
6
7
|
export const PROJECT_SUMMARY_FILENAME = "project-summary.json";
|
|
7
8
|
|
|
@@ -12,7 +13,7 @@ export const textResponse = (text) => ({
|
|
|
12
13
|
content: [
|
|
13
14
|
{
|
|
14
15
|
type: "text",
|
|
15
|
-
text,
|
|
16
|
+
text: sanitize(text),
|
|
16
17
|
},
|
|
17
18
|
],
|
|
18
19
|
});
|
|
@@ -54,6 +55,7 @@ export function formatChecklist(checks) {
|
|
|
54
55
|
}
|
|
55
56
|
|
|
56
57
|
export function summarizeHistory(history, limit) {
|
|
58
|
+
history = sanitize(history);
|
|
57
59
|
const recent = history.slice(-limit).reverse();
|
|
58
60
|
if (recent.length === 0) {
|
|
59
61
|
return null;
|
|
@@ -70,6 +72,7 @@ export function summarizeHistory(history, limit) {
|
|
|
70
72
|
}
|
|
71
73
|
|
|
72
74
|
export function generateProjectSummary(history) {
|
|
75
|
+
history = sanitize(history);
|
|
73
76
|
if (!history || history.length === 0) {
|
|
74
77
|
return "No workflow history available.";
|
|
75
78
|
}
|
|
@@ -137,7 +140,7 @@ export async function loadProjectSummary(workflowState) {
|
|
|
137
140
|
const summaryPath = path.join(path.dirname(workflowState.stateFile), PROJECT_SUMMARY_FILENAME);
|
|
138
141
|
try {
|
|
139
142
|
const data = await fs.readFile(summaryPath, "utf-8");
|
|
140
|
-
return JSON.parse(data);
|
|
143
|
+
return sanitize(JSON.parse(data));
|
|
141
144
|
} catch (error) {
|
|
142
145
|
if (error?.code === "ENOENT") {
|
|
143
146
|
return null;
|
|
@@ -14,7 +14,7 @@ export async function handleGetWorkflowStatus(workflowState) {
|
|
|
14
14
|
const owner = disk.owner || {};
|
|
15
15
|
sharedWarning =
|
|
16
16
|
`\n\n⚠️ PERINGATAN: berkas state ini terakhir ditulis proses MCP LAIN ` +
|
|
17
|
-
`(${owner.
|
|
17
|
+
`(${owner.processMarker || "tanpa-token"}, pid ${owner.pid ?? "?"}).\n` +
|
|
18
18
|
` State workflow dipakai bersama antar sesi di mesin ini.\n` +
|
|
19
19
|
` Periksa apakah "Task:" di atas benar-benar milik Anda SEBELUM commit_and_push.`;
|
|
20
20
|
if (disk.currentPhase && disk.currentPhase !== "idle" && disk.taskDescription) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from "fs/promises";
|
|
2
2
|
import path from "path";
|
|
3
|
+
import { sanitize } from '../../security/sanitizer.js';
|
|
3
4
|
import { textResponse, generateProjectSummary, summarizeHistory, PROJECT_SUMMARY_FILENAME } from "./shared.js";
|
|
4
5
|
|
|
5
6
|
export function handleProjectSummary(workflowState) {
|
|
@@ -11,7 +12,7 @@ export async function handleProjectSummaryData(workflowState) {
|
|
|
11
12
|
const summaryPath = path.join(path.dirname(workflowState.stateFile), PROJECT_SUMMARY_FILENAME);
|
|
12
13
|
try {
|
|
13
14
|
const data = await fs.readFile(summaryPath, "utf-8");
|
|
14
|
-
const parsed = JSON.parse(data);
|
|
15
|
+
const parsed = sanitize(JSON.parse(data));
|
|
15
16
|
const formatted = `🧠 Project Knowledge Summary (from persisted data)\n\n📊 Stats\n- Total tasks completed: ${parsed.totalTasks}\n- Recent task mix: ${Object.entries(parsed.taskTypes).map(([t, c]) => `${t}: ${c}`).join(", ")}\n- Last active: ${parsed.lastActive ? new Date(parsed.lastActive).toLocaleDateString() : "Never"}\n- Updated: ${new Date(parsed.updatedAt).toLocaleString()}\n\n📝 Recent tasks (last 5)\n${parsed.recentTasks.map((e, i) => `${i + 1}. ${e.description} (${e.type})`).join("\n")}`;
|
|
16
17
|
return textResponse(formatted);
|
|
17
18
|
} catch (error) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { classify } from '../../security/policy.js';
|
|
1
2
|
import { textResponse, isReleaseSatisfied, isErrorResponse, ensurePrdExists } from "./shared.js";
|
|
2
3
|
|
|
3
4
|
export async function handleStartTask(args, workflowState) {
|
|
@@ -8,6 +9,7 @@ export async function handleStartTask(args, workflowState) {
|
|
|
8
9
|
const { archivedTask } = workflowState.reset({ archive: true });
|
|
9
10
|
|
|
10
11
|
workflowState.state.currentPhase = "coding";
|
|
12
|
+
workflowState.state.classification = classify(args);
|
|
11
13
|
workflowState.state.taskDescription = args.description;
|
|
12
14
|
workflowState.state.taskType = args.type;
|
|
13
15
|
workflowState.state.testsSkipped = false;
|
package/utils.js
CHANGED
|
@@ -3,6 +3,7 @@ const isWindows = process.platform === "win32";
|
|
|
3
3
|
export function shellEscape(value) {
|
|
4
4
|
const stringValue = String(value);
|
|
5
5
|
if (isWindows) {
|
|
6
|
+
if(/["%!\r\n]/.test(stringValue)) throw new Error('Unsafe Windows shell argument denied; use a trusted operator session for complex messages.');
|
|
6
7
|
// Windows cmd.exe requires double quotes for arguments with spaces.
|
|
7
8
|
// Escape existing double quotes with backslash.
|
|
8
9
|
return `"${stringValue.replace(/"/g, '\\"')}"`;
|
package/verify-build.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { safeLog } from './security/sanitizer.js';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Build Verification Script
|
|
@@ -22,16 +23,16 @@ let failedTests = 0;
|
|
|
22
23
|
function test(name, fn) {
|
|
23
24
|
try {
|
|
24
25
|
fn();
|
|
25
|
-
|
|
26
|
+
safeLog('info', `✓ ${name}`);
|
|
26
27
|
passedTests++;
|
|
27
28
|
} catch (error) {
|
|
28
|
-
|
|
29
|
-
|
|
29
|
+
safeLog('error', `✗ ${name}`);
|
|
30
|
+
safeLog('error', ` Error: ${error.message}`);
|
|
30
31
|
failedTests++;
|
|
31
32
|
}
|
|
32
33
|
}
|
|
33
34
|
|
|
34
|
-
|
|
35
|
+
safeLog('info', '\n🧪 Running Build Verification Tests...\n');
|
|
35
36
|
|
|
36
37
|
// Test 1: Build succeeds
|
|
37
38
|
test('Build completes successfully', () => {
|
|
@@ -166,11 +167,11 @@ test('Vertical monitor responsive design', () => {
|
|
|
166
167
|
}
|
|
167
168
|
});
|
|
168
169
|
|
|
169
|
-
|
|
170
|
+
safeLog('info', `\n📊 Test Results: ${passedTests} passed, ${failedTests} failed\n`);
|
|
170
171
|
|
|
171
172
|
if (failedTests > 0) {
|
|
172
173
|
process.exit(1);
|
|
173
174
|
} else {
|
|
174
|
-
|
|
175
|
+
safeLog('info', '✅ All tests passed!\n');
|
|
175
176
|
process.exit(0);
|
|
176
177
|
}
|
package/vite.config.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { defineConfig } from 'vite';
|
|
2
2
|
import { fileURLToPath } from 'url';
|
|
3
3
|
import path from 'path';
|
|
4
|
+
import { builtinModules } from 'node:module';
|
|
4
5
|
|
|
5
6
|
const __filename = fileURLToPath(import.meta.url);
|
|
6
7
|
const __dirname = path.dirname(__filename);
|
|
@@ -17,6 +18,9 @@ export default defineConfig({
|
|
|
17
18
|
format: 'es',
|
|
18
19
|
},
|
|
19
20
|
external: [
|
|
21
|
+
...builtinModules,
|
|
22
|
+
...builtinModules.map(name=>`node:${name}`),
|
|
23
|
+
'pg','mysql2','mysql2/promise','better-sqlite3',
|
|
20
24
|
'@modelcontextprotocol/sdk',
|
|
21
25
|
// dotenv ships CommonJS only (dist/index.cjs). Bundling it into an ES
|
|
22
26
|
// module leaks its `require.main === module` guard, which throws
|
package/workflow-state.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import { safeLog } from './security/sanitizer.js';
|
|
1
2
|
import fs from "fs/promises";
|
|
2
3
|
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from "fs";
|
|
3
4
|
import path from "path";
|
|
5
|
+
import { assertSafeKnowledge } from './security/policy.js';
|
|
6
|
+
import { sanitize } from './security/sanitizer.js';
|
|
4
7
|
import { fileURLToPath } from "url";
|
|
5
8
|
|
|
6
9
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -30,6 +33,14 @@ let cachedUserStateDir = null;
|
|
|
30
33
|
*/
|
|
31
34
|
const PROCESS_TOKEN = `proc-${process.pid}-${Math.random().toString(16).slice(2, 10)}`;
|
|
32
35
|
|
|
36
|
+
function migrateOwnerMetadata(value) {
|
|
37
|
+
if(value?.owner && !value.owner.processMarker && /^proc-[A-Za-z0-9-]+$/.test(value.owner.token || '')) {
|
|
38
|
+
value.owner.processMarker=value.owner.token;
|
|
39
|
+
delete value.owner.token;
|
|
40
|
+
}
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
|
|
33
44
|
function generateUserId() {
|
|
34
45
|
const timestamp = Date.now().toString(16);
|
|
35
46
|
const randomSuffix = Math.random().toString(16).slice(2, 10);
|
|
@@ -249,6 +260,8 @@ async function migrateLegacyStateFile(targetPath) {
|
|
|
249
260
|
}
|
|
250
261
|
|
|
251
262
|
await fs.mkdir(stateDir, { recursive: true });
|
|
263
|
+
const migrated = migrateOwnerMetadata(JSON.parse(await fs.readFile(legacyPath, 'utf8')));
|
|
264
|
+
assertSafeKnowledge(migrated);
|
|
252
265
|
|
|
253
266
|
try {
|
|
254
267
|
await fs.access(targetPath);
|
|
@@ -258,7 +271,8 @@ async function migrateLegacyStateFile(targetPath) {
|
|
|
258
271
|
// New state file doesn't exist yet; fall through to migrate.
|
|
259
272
|
}
|
|
260
273
|
|
|
261
|
-
await fs.
|
|
274
|
+
await fs.writeFile(targetPath, JSON.stringify(migrated, null, 2), { flag: 'wx' });
|
|
275
|
+
await fs.unlink(legacyPath);
|
|
262
276
|
}
|
|
263
277
|
|
|
264
278
|
function isRootPath(candidate) {
|
|
@@ -338,6 +352,7 @@ async function ensureCompatibilityLinks(primaryPath) {
|
|
|
338
352
|
if (!(await pathExists(resolvedPrimary))) {
|
|
339
353
|
return;
|
|
340
354
|
}
|
|
355
|
+
assertSafeKnowledge(JSON.parse(await fs.readFile(resolvedPrimary, 'utf8')));
|
|
341
356
|
|
|
342
357
|
const compatibilityPaths = getCompatibilityPaths(resolvedPrimary);
|
|
343
358
|
|
|
@@ -413,18 +428,38 @@ async function ensureCompatibilityLinks(primaryPath) {
|
|
|
413
428
|
*
|
|
414
429
|
* MENGAPA ADA
|
|
415
430
|
* -----------
|
|
416
|
-
* Berkas state dulu disimpan per USER, jadi
|
|
431
|
+
* Berkas state dulu disimpan per USER, jadi beberapa proses MCP berbagi satu
|
|
417
432
|
* `workflow-state.json`. Akibatnya `start_task` di satu sesi mengganti task
|
|
418
433
|
* sesi lain di tengah jalan, dan gerbang kualitas (ready check, tests, docs)
|
|
419
434
|
* ikut dibagi sehingga sesi B bisa memakai hasil sesi A untuk commit.
|
|
420
435
|
*
|
|
421
436
|
* Sekarang task dan gerbangnya milik SESI; `history` tetap milik PROYEK.
|
|
422
437
|
*
|
|
423
|
-
*
|
|
424
|
-
*
|
|
425
|
-
*
|
|
438
|
+
* MENGAPA BUKAN ENV SAJA
|
|
439
|
+
* ----------------------
|
|
440
|
+
* Percobaan pertama membaca `OPENCODE_SESSION_ID` dari environment. OpenCode
|
|
441
|
+
* tidak men-set variabel itu, sehingga hampir semua berkas jatuh ke
|
|
442
|
+
* `pid-<pid>`; karena satu proses MCP dipakai bergantian oleh beberapa sesi,
|
|
443
|
+
* sesi-sesi itu tetap berbagi satu berkas dan saling menimpa.
|
|
444
|
+
*
|
|
445
|
+
* OpenCode v2 mengirim identitas sesi per panggilan di
|
|
446
|
+
* `CallToolRequest.params._meta["ai.opencode/sessionID"]`. Itu sumber utama
|
|
447
|
+
* sekarang; env tetap dibaca sebagai cadangan untuk tes dan pemanggil lain.
|
|
426
448
|
*/
|
|
427
|
-
export
|
|
449
|
+
export const SESSION_META_KEY = "ai.opencode/sessionID";
|
|
450
|
+
|
|
451
|
+
/** Mengambil identitas sesi dari request metadata MCP, atau null. */
|
|
452
|
+
export function sessionKeyFromRequest(request) {
|
|
453
|
+
const value = request?.params?._meta?.[SESSION_META_KEY];
|
|
454
|
+
if (typeof value !== "string") return null;
|
|
455
|
+
const trimmed = value.trim();
|
|
456
|
+
return trimmed === "" ? null : trimmed;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
export function resolveSessionKey(env = process.env, explicitSessionId = null) {
|
|
460
|
+
const fromRequest = typeof explicitSessionId === "string" ? explicitSessionId.trim() : "";
|
|
461
|
+
if (fromRequest) return sanitizeSessionKey(fromRequest);
|
|
462
|
+
|
|
428
463
|
for (const candidate of [env.DEV_WORKFLOW_SESSION_ID, env.OPENCODE_SESSION_ID, env.CLIENT_SESSION_ID]) {
|
|
429
464
|
const value = typeof candidate === "string" ? candidate.trim() : "";
|
|
430
465
|
if (value) return sanitizeSessionKey(value);
|
|
@@ -438,8 +473,8 @@ function sanitizeSessionKey(value) {
|
|
|
438
473
|
}
|
|
439
474
|
|
|
440
475
|
/** Nama berkas state untuk sesi ini, di direktori user yang sama. */
|
|
441
|
-
function sessionStateFilename(env = process.env) {
|
|
442
|
-
return `workflow-state.${resolveSessionKey(env)}.json`;
|
|
476
|
+
function sessionStateFilename(env = process.env, explicitSessionId = null) {
|
|
477
|
+
return `workflow-state.${resolveSessionKey(env, explicitSessionId)}.json`;
|
|
443
478
|
}
|
|
444
479
|
|
|
445
480
|
/**
|
|
@@ -486,7 +521,7 @@ export function resolveProjectRootForState(stateFilePath) {
|
|
|
486
521
|
return path.dirname(resolved);
|
|
487
522
|
}
|
|
488
523
|
|
|
489
|
-
export function resolveStateFile() {
|
|
524
|
+
export function resolveStateFile({ sessionId = null } = {}) {
|
|
490
525
|
const customPath = process.env.DEV_WORKFLOW_STATE_FILE;
|
|
491
526
|
if (customPath) {
|
|
492
527
|
return path.resolve(customPath);
|
|
@@ -516,7 +551,7 @@ export function resolveStateFile() {
|
|
|
516
551
|
|
|
517
552
|
const stateDir = path.join(projectRoot, STATE_DIR);
|
|
518
553
|
const userDir = getUserScopedDir(stateDir);
|
|
519
|
-
return path.join(userDir, sessionStateFilename());
|
|
554
|
+
return path.join(userDir, sessionStateFilename(process.env, sessionId));
|
|
520
555
|
}
|
|
521
556
|
|
|
522
557
|
export class WorkflowState {
|
|
@@ -525,6 +560,48 @@ export class WorkflowState {
|
|
|
525
560
|
// History milik proyek, state milik sesi. Dalam tata letak nyata keduanya
|
|
526
561
|
// berkas berbeda; untuk path eksplisit keduanya menunjuk berkas yang sama.
|
|
527
562
|
this.historyFile = resolveHistoryFile(stateFilePath);
|
|
563
|
+
this.state = {
|
|
564
|
+
currentPhase: "idle",
|
|
565
|
+
classification: "INTERNAL",
|
|
566
|
+
taskDescription: "",
|
|
567
|
+
bugFixed: false,
|
|
568
|
+
testsCreated: false,
|
|
569
|
+
testsPassed: false,
|
|
570
|
+
testsSkipped: false,
|
|
571
|
+
testsSkippedReason: "",
|
|
572
|
+
documentationCreated: false,
|
|
573
|
+
readyToCommit: false,
|
|
574
|
+
readyCheckCompleted: false,
|
|
575
|
+
released: false,
|
|
576
|
+
releaseSkipped: false,
|
|
577
|
+
releaseSkippedReason: "",
|
|
578
|
+
releaseCommand: "",
|
|
579
|
+
releaseNotes: "",
|
|
580
|
+
commitAndPushCompleted: false,
|
|
581
|
+
lastCommitMessage: "",
|
|
582
|
+
lastPushBranch: "",
|
|
583
|
+
featureFlowCreated: false,
|
|
584
|
+
featureFlowDescription: "",
|
|
585
|
+
history: [],
|
|
586
|
+
taskType: "",
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Berpindah ke state milik sesi tertentu.
|
|
592
|
+
*
|
|
593
|
+
* Satu proses MCP bisa melayani beberapa sesi bergantian, jadi state harus
|
|
594
|
+
* ditentukan per panggilan - bukan sekali saat proses start. Mengembalikan
|
|
595
|
+
* true bila berkas benar-benar berpindah.
|
|
596
|
+
*/
|
|
597
|
+
async useSession(sessionId) {
|
|
598
|
+
const target = resolveStateFile({ sessionId });
|
|
599
|
+
if (target === this.stateFile) return false;
|
|
600
|
+
|
|
601
|
+
this.stateFile = target;
|
|
602
|
+
this.historyFile = resolveHistoryFile(target);
|
|
603
|
+
// Mulai dari state bersih, lalu muat milik sesi itu. Menyalin state sesi
|
|
604
|
+
// sebelumnya akan membuat sesi baru mewarisi gerbang kualitas sesi lain.
|
|
528
605
|
this.state = {
|
|
529
606
|
currentPhase: "idle",
|
|
530
607
|
taskDescription: "",
|
|
@@ -549,13 +626,15 @@ export class WorkflowState {
|
|
|
549
626
|
history: [],
|
|
550
627
|
taskType: "",
|
|
551
628
|
};
|
|
629
|
+
await this.load();
|
|
630
|
+
return true;
|
|
552
631
|
}
|
|
553
632
|
|
|
554
633
|
async load() {
|
|
555
634
|
await migrateLegacyStateFile(this.stateFile);
|
|
556
635
|
try {
|
|
557
636
|
const data = await fs.readFile(this.stateFile, "utf-8");
|
|
558
|
-
const parsed = JSON.parse(data);
|
|
637
|
+
const parsed = sanitize(migrateOwnerMetadata(JSON.parse(data)));
|
|
559
638
|
this.state = {
|
|
560
639
|
...this.state,
|
|
561
640
|
...parsed,
|
|
@@ -663,11 +742,14 @@ export class WorkflowState {
|
|
|
663
742
|
}
|
|
664
743
|
}
|
|
665
744
|
|
|
745
|
+
const payload = { ...shared, history: merged };
|
|
746
|
+
assertSafeKnowledge(payload);
|
|
666
747
|
await fs.mkdir(path.dirname(this.historyFile), { recursive: true });
|
|
667
|
-
await fs.writeFile(this.historyFile, JSON.stringify(
|
|
748
|
+
await fs.writeFile(this.historyFile, JSON.stringify(payload, null, 2));
|
|
668
749
|
}
|
|
669
750
|
|
|
670
751
|
async ensurePrimaryFile() {
|
|
752
|
+
assertSafeKnowledge(this.state);
|
|
671
753
|
let created = false;
|
|
672
754
|
try {
|
|
673
755
|
await fs.access(this.stateFile);
|
|
@@ -685,9 +767,11 @@ export class WorkflowState {
|
|
|
685
767
|
}
|
|
686
768
|
|
|
687
769
|
async save() {
|
|
770
|
+
assertSafeKnowledge(this.state);
|
|
688
771
|
const reconciled = await this.reconcileWithDisk();
|
|
772
|
+
assertSafeKnowledge(this.state);
|
|
689
773
|
this.state.owner = {
|
|
690
|
-
|
|
774
|
+
processMarker: PROCESS_TOKEN,
|
|
691
775
|
pid: process.pid,
|
|
692
776
|
updatedAt: new Date().toISOString(),
|
|
693
777
|
};
|
|
@@ -709,7 +793,7 @@ export class WorkflowState {
|
|
|
709
793
|
async readDiskState() {
|
|
710
794
|
try {
|
|
711
795
|
const data = await fs.readFile(this.stateFile, "utf-8");
|
|
712
|
-
return JSON.parse(data);
|
|
796
|
+
return sanitize(migrateOwnerMetadata(JSON.parse(data)));
|
|
713
797
|
} catch {
|
|
714
798
|
return null;
|
|
715
799
|
}
|
|
@@ -717,7 +801,7 @@ export class WorkflowState {
|
|
|
717
801
|
|
|
718
802
|
/** True kalau berkas di disk terakhir ditulis proses MCP lain. */
|
|
719
803
|
isForeignWrite(diskState) {
|
|
720
|
-
const token = diskState?.owner?.
|
|
804
|
+
const token = diskState?.owner?.processMarker;
|
|
721
805
|
return typeof token === "string" && token.length > 0 && token !== PROCESS_TOKEN;
|
|
722
806
|
}
|
|
723
807
|
|
|
@@ -768,7 +852,7 @@ export class WorkflowState {
|
|
|
768
852
|
return result;
|
|
769
853
|
}
|
|
770
854
|
|
|
771
|
-
result.foreignToken = disk.owner.
|
|
855
|
+
result.foreignToken = disk.owner.processMarker;
|
|
772
856
|
|
|
773
857
|
const hasRunningTask =
|
|
774
858
|
typeof disk.taskDescription === "string" &&
|
|
@@ -788,6 +872,7 @@ export class WorkflowState {
|
|
|
788
872
|
}
|
|
789
873
|
|
|
790
874
|
this.state.history.push({
|
|
875
|
+
classification: disk.classification || "INTERNAL",
|
|
791
876
|
taskDescription: disk.taskDescription,
|
|
792
877
|
taskType: disk.taskType || "other",
|
|
793
878
|
commitMessage: disk.lastCommitMessage || "",
|
|
@@ -796,7 +881,7 @@ export class WorkflowState {
|
|
|
796
881
|
dropReason:
|
|
797
882
|
"Ditimpa proses agen lain: state workflow dipakai bersama antar sesi di mesin ini",
|
|
798
883
|
interruptedAtPhase: disk.currentPhase,
|
|
799
|
-
interruptedBy: disk.owner.
|
|
884
|
+
interruptedBy: disk.owner.processMarker,
|
|
800
885
|
timestamp: new Date().toISOString(),
|
|
801
886
|
});
|
|
802
887
|
result.preservedTask = disk.taskDescription;
|
|
@@ -804,13 +889,16 @@ export class WorkflowState {
|
|
|
804
889
|
}
|
|
805
890
|
|
|
806
891
|
async updateProjectSummary() {
|
|
892
|
+
assertSafeKnowledge(this.state);
|
|
807
893
|
const summaryPath = path.join(path.dirname(this.stateFile), PROJECT_SUMMARY_FILENAME);
|
|
808
894
|
const summary = this.generateProjectSummaryData();
|
|
895
|
+
assertSafeKnowledge(summary);
|
|
809
896
|
await fs.mkdir(path.dirname(summaryPath), { recursive: true });
|
|
810
897
|
await fs.writeFile(summaryPath, JSON.stringify(summary, null, 2));
|
|
811
898
|
}
|
|
812
899
|
|
|
813
900
|
async syncToDatabase() {
|
|
901
|
+
assertSafeKnowledge(this.state);
|
|
814
902
|
try {
|
|
815
903
|
const { insertHistoryEntry, updateSummaryForUser, saveState } = await import("./db/index.js");
|
|
816
904
|
const userId = getCurrentUserId();
|
|
@@ -826,7 +914,7 @@ export class WorkflowState {
|
|
|
826
914
|
await saveState(userId, projectRoot, this.state);
|
|
827
915
|
} catch (error) {
|
|
828
916
|
// Database sync failed; log it but don't crash
|
|
829
|
-
|
|
917
|
+
safeLog('error', "Database sync failed:", error.message);
|
|
830
918
|
}
|
|
831
919
|
}
|
|
832
920
|
|
|
@@ -892,6 +980,7 @@ export class WorkflowState {
|
|
|
892
980
|
if (archive && hasRunningTask) {
|
|
893
981
|
archivedTask = previous.taskDescription;
|
|
894
982
|
history.push({
|
|
983
|
+
classification: previous.classification || "INTERNAL",
|
|
895
984
|
taskDescription: previous.taskDescription,
|
|
896
985
|
taskType: previous.taskType || "other",
|
|
897
986
|
commitMessage: previous.lastCommitMessage || "",
|
|
@@ -906,6 +995,7 @@ export class WorkflowState {
|
|
|
906
995
|
|
|
907
996
|
this.state = {
|
|
908
997
|
currentPhase: "idle",
|
|
998
|
+
classification: "INTERNAL",
|
|
909
999
|
taskDescription: "",
|
|
910
1000
|
bugFixed: false,
|
|
911
1001
|
testsCreated: false,
|
|
@@ -934,6 +1024,7 @@ export class WorkflowState {
|
|
|
934
1024
|
|
|
935
1025
|
addToHistory(entry) {
|
|
936
1026
|
this.state.history.push({
|
|
1027
|
+
classification: this.state.classification || "INTERNAL",
|
|
937
1028
|
...entry,
|
|
938
1029
|
timestamp: new Date().toISOString(),
|
|
939
1030
|
});
|