patchwarden 1.1.0 → 1.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +83 -8
- package/README.md +78 -10
- package/dist/assessments/agentAssessor.d.ts +1 -1
- package/dist/assessments/agentAssessor.js +3 -3
- package/dist/assessments/assessmentStore.d.ts +1 -1
- package/dist/assessments/assessmentStore.js +2 -2
- package/dist/assessments/confirmCli.js +5 -4
- package/dist/config.d.ts +7 -0
- package/dist/config.js +34 -0
- package/dist/control/middleware/auth.d.ts +10 -0
- package/dist/control/middleware/auth.js +8 -0
- package/dist/control/middleware/static.d.ts +3 -0
- package/dist/control/middleware/static.js +65 -0
- package/dist/control/routes/audit.d.ts +15 -0
- package/dist/control/routes/audit.js +277 -0
- package/dist/control/routes/evidence.d.ts +4 -0
- package/dist/control/routes/evidence.js +96 -0
- package/dist/control/routes/lineage.d.ts +3 -0
- package/dist/control/routes/lineage.js +71 -0
- package/dist/control/routes/policy.d.ts +3 -0
- package/dist/control/routes/policy.js +81 -0
- package/dist/control/routes/process.d.ts +5 -0
- package/dist/control/routes/process.js +200 -0
- package/dist/control/routes/sessions.d.ts +22 -0
- package/dist/control/routes/sessions.js +224 -0
- package/dist/control/routes/status.d.ts +6 -0
- package/dist/control/routes/status.js +250 -0
- package/dist/control/routes/taskActions.d.ts +21 -0
- package/dist/control/routes/taskActions.js +233 -0
- package/dist/control/routes/tasks.d.ts +20 -0
- package/dist/control/routes/tasks.js +310 -0
- package/dist/control/routes/workspace.d.ts +15 -0
- package/dist/control/routes/workspace.js +193 -0
- package/dist/control/runtime.d.ts +91 -0
- package/dist/control/runtime.js +392 -0
- package/dist/control/server.d.ts +13 -0
- package/dist/control/server.js +479 -0
- package/dist/control/shared.d.ts +35 -0
- package/dist/control/shared.js +288 -0
- package/dist/controlCenter.d.ts +6 -0
- package/dist/controlCenter.js +7 -2001
- package/dist/direct/directGuards.js +30 -8
- package/dist/direct/directSessionStore.d.ts +2 -0
- package/dist/direct/directVerification.js +7 -0
- package/dist/doctor.d.ts +18 -1
- package/dist/doctor.js +579 -348
- package/dist/goal/goalReport.d.ts +54 -0
- package/dist/goal/goalReport.js +204 -0
- package/dist/goal/goalStatus.d.ts +6 -0
- package/dist/goal/specKitImport.d.ts +63 -0
- package/dist/goal/specKitImport.js +220 -0
- package/dist/goal/subgoalSync.js +2 -1
- package/dist/httpServer.js +15 -12
- package/dist/index.js +7 -4
- package/dist/logging.d.ts +7 -1
- package/dist/logging.js +8 -0
- package/dist/policy/projectPolicy.d.ts +55 -0
- package/dist/policy/projectPolicy.js +286 -0
- package/dist/runner/changeCapture.d.ts +3 -3
- package/dist/runner/changeCapture.js +63 -39
- package/dist/runner/cli.js +7 -6
- package/dist/runner/postTaskCleanup.js +26 -2
- package/dist/runner/runTask.js +245 -221
- package/dist/runner/simpleProcess.js +4 -4
- package/dist/runner/watch.js +17 -14
- package/dist/security/contentRedaction.d.ts +6 -0
- package/dist/security/contentRedaction.js +22 -0
- package/dist/smoke-test.js +261 -255
- package/dist/test/unit/apply-patch.test.d.ts +1 -0
- package/dist/test/unit/apply-patch.test.js +225 -0
- package/dist/test/unit/create-task.test.d.ts +1 -0
- package/dist/test/unit/create-task.test.js +197 -0
- package/dist/test/unit/direct-guards.test.js +124 -9
- package/dist/test/unit/evidence-pack.test.d.ts +1 -0
- package/dist/test/unit/evidence-pack.test.js +224 -0
- package/dist/test/unit/get-task-status.test.d.ts +1 -0
- package/dist/test/unit/get-task-status.test.js +174 -0
- package/dist/test/unit/get-task-summary.test.d.ts +1 -0
- package/dist/test/unit/get-task-summary.test.js +146 -0
- package/dist/test/unit/goal-report.test.d.ts +1 -0
- package/dist/test/unit/goal-report.test.js +159 -0
- package/dist/test/unit/goal-subgoal-task.test.js +6 -6
- package/dist/test/unit/goal-tools-registry.test.js +6 -4
- package/dist/test/unit/path-guard.test.js +24 -0
- package/dist/test/unit/project-policy-release-mode.test.d.ts +1 -0
- package/dist/test/unit/project-policy-release-mode.test.js +125 -0
- package/dist/test/unit/run-task-loop.test.d.ts +1 -0
- package/dist/test/unit/run-task-loop.test.js +380 -0
- package/dist/test/unit/schema-drift-check.test.js +10 -9
- package/dist/test/unit/spec-kit-import.test.d.ts +1 -0
- package/dist/test/unit/spec-kit-import.test.js +341 -0
- package/dist/test/unit/wait-for-task.test.d.ts +1 -0
- package/dist/test/unit/wait-for-task.test.js +144 -0
- package/dist/tools/auditTask.d.ts +8 -63
- package/dist/tools/auditTask.js +12 -8
- package/dist/tools/createDirectSession.d.ts +1 -1
- package/dist/tools/createDirectSession.js +2 -2
- package/dist/tools/createTask.d.ts +2 -2
- package/dist/tools/createTask.js +4 -4
- package/dist/tools/dispatch/coreDispatch.d.ts +9 -0
- package/dist/tools/dispatch/coreDispatch.js +282 -0
- package/dist/tools/dispatch/diagnosticDispatch.d.ts +14 -0
- package/dist/tools/dispatch/diagnosticDispatch.js +78 -0
- package/dist/tools/dispatch/directDispatch.d.ts +8 -0
- package/dist/tools/dispatch/directDispatch.js +115 -0
- package/dist/tools/dispatch/goalDispatch.d.ts +8 -0
- package/dist/tools/dispatch/goalDispatch.js +91 -0
- package/dist/tools/dispatch/releaseDispatch.d.ts +8 -0
- package/dist/tools/dispatch/releaseDispatch.js +45 -0
- package/dist/tools/dispatch/types.d.ts +23 -0
- package/dist/tools/dispatch/types.js +15 -0
- package/dist/tools/evidencePack.d.ts +45 -0
- package/dist/tools/evidencePack.js +375 -0
- package/dist/tools/finalizeDirectSession.d.ts +1 -1
- package/dist/tools/finalizeDirectSession.js +3 -3
- package/dist/tools/goalSubgoalTask.d.ts +1 -1
- package/dist/tools/goalSubgoalTask.js +2 -2
- package/dist/tools/healthCheck.js +3 -3
- package/dist/tools/recommendAgentForTask.d.ts +19 -0
- package/dist/tools/recommendAgentForTask.js +56 -0
- package/dist/tools/registry.d.ts +3 -3
- package/dist/tools/registry.js +333 -402
- package/dist/tools/releaseMode.d.ts +50 -0
- package/dist/tools/releaseMode.js +370 -0
- package/dist/tools/retryTask.d.ts +2 -2
- package/dist/tools/retryTask.js +2 -2
- package/dist/tools/runDirectVerificationBundle.d.ts +26 -0
- package/dist/tools/runDirectVerificationBundle.js +64 -0
- package/dist/tools/runTaskLoop.d.ts +57 -0
- package/dist/tools/runTaskLoop.js +417 -0
- package/dist/tools/runVerification.d.ts +4 -0
- package/dist/tools/runVerification.js +4 -0
- package/dist/tools/safeViews.d.ts +8 -2
- package/dist/tools/safeViews.js +4 -2
- package/dist/tools/taskLineage.d.ts +91 -0
- package/dist/tools/taskLineage.js +175 -0
- package/dist/tools/toolCatalog.d.ts +2 -2
- package/dist/tools/toolCatalog.js +6 -0
- package/dist/tools/toolRegistry.js +132 -0
- package/dist/version.d.ts +2 -2
- package/dist/version.js +2 -2
- package/docs/CODE_WIKI.md +983 -0
- package/docs/agentseal-integration.md +150 -0
- package/docs/architecture.md +63 -0
- package/docs/assets/patchwarden-oss-demo.gif +0 -0
- package/docs/chatgpt-usage.md +31 -0
- package/docs/control-center/README.md +9 -0
- package/docs/dashboard-overview.md +86 -0
- package/docs/demo.md +8 -0
- package/docs/direct-session-workflow.md +98 -0
- package/docs/evidence-pack-schema.md +215 -0
- package/docs/execution-plan-2026-07-09.md +315 -0
- package/docs/lineage-evidence-pack-workflow.md +127 -0
- package/docs/mcp-inspector-testing.md +200 -0
- package/docs/open-source-application.md +162 -0
- package/docs/opencode-worker.md +151 -0
- package/docs/openhands-worker.md +181 -0
- package/docs/release-evidence.md +72 -0
- package/docs/roadmap-execution-and-acceptance.md +365 -0
- package/docs/spec-kit-integration.md +131 -0
- package/docs/task-safe-review-workflow.md +98 -0
- package/docs/threat-model.md +79 -0
- package/docs/user-feedback.md +40 -0
- package/docs/why-patchwarden.md +110 -0
- package/package.json +3 -3
- package/scripts/checks/control-center-smoke.js +443 -0
- package/scripts/checks/control-smoke.js +2 -2
- package/scripts/checks/lifecycle-smoke.js +29 -23
- package/scripts/checks/mcp-manifest-check.js +12 -0
- package/scripts/checks/mcp-smoke.js +33 -7
- package/scripts/checks/watcher-supervisor-smoke.js +1 -1
- package/scripts/generate-demo-gif.py +320 -0
- package/src/assessments/agentAssessor.ts +3 -3
- package/src/assessments/assessmentStore.ts +2 -2
- package/src/assessments/confirmCli.ts +5 -4
- package/src/config.ts +37 -0
- package/src/control/middleware/auth.ts +17 -0
- package/src/control/middleware/static.ts +71 -0
- package/src/control/routes/audit.ts +321 -0
- package/src/control/routes/evidence.ts +107 -0
- package/src/control/routes/lineage.ts +92 -0
- package/src/control/routes/policy.ts +81 -0
- package/src/control/routes/process.ts +204 -0
- package/src/control/routes/sessions.ts +251 -0
- package/src/control/routes/status.ts +325 -0
- package/src/control/routes/taskActions.ts +248 -0
- package/src/control/routes/tasks.ts +323 -0
- package/src/control/routes/workspace.ts +203 -0
- package/src/control/runtime.ts +472 -0
- package/src/control/server.ts +471 -0
- package/src/control/shared.ts +294 -0
- package/src/controlCenter.ts +7 -2150
- package/src/direct/directGuards.ts +28 -7
- package/src/direct/directSessionStore.ts +2 -0
- package/src/direct/directVerification.ts +7 -0
- package/src/doctor.ts +741 -481
- package/src/goal/goalReport.ts +271 -0
- package/src/goal/goalStatus.ts +6 -0
- package/src/goal/specKitImport.ts +355 -0
- package/src/goal/subgoalSync.ts +4 -2
- package/src/httpServer.ts +17 -14
- package/src/index.ts +7 -4
- package/src/logging.ts +10 -1
- package/src/policy/projectPolicy.ts +344 -0
- package/src/runner/changeCapture.ts +70 -42
- package/src/runner/cli.ts +7 -6
- package/src/runner/postTaskCleanup.ts +26 -2
- package/src/runner/runTask.ts +325 -223
- package/src/runner/simpleProcess.ts +4 -4
- package/src/runner/watch.ts +17 -14
- package/src/security/contentRedaction.ts +29 -0
- package/src/smoke-test.ts +254 -252
- package/src/test/unit/apply-patch.test.ts +293 -0
- package/src/test/unit/create-task.test.ts +255 -0
- package/src/test/unit/direct-guards.test.ts +178 -8
- package/src/test/unit/evidence-pack.test.ts +251 -0
- package/src/test/unit/get-task-status.test.ts +203 -0
- package/src/test/unit/get-task-summary.test.ts +173 -0
- package/src/test/unit/goal-report.test.ts +189 -0
- package/src/test/unit/goal-subgoal-task.test.ts +6 -6
- package/src/test/unit/goal-tools-registry.test.ts +7 -5
- package/src/test/unit/path-guard.test.ts +30 -0
- package/src/test/unit/project-policy-release-mode.test.ts +156 -0
- package/src/test/unit/run-task-loop.test.ts +425 -0
- package/src/test/unit/schema-drift-check.test.ts +11 -9
- package/src/test/unit/spec-kit-import.test.ts +429 -0
- package/src/test/unit/wait-for-task.test.ts +176 -0
- package/src/tools/auditTask.ts +99 -59
- package/src/tools/createDirectSession.ts +3 -3
- package/src/tools/createTask.ts +7 -7
- package/src/tools/dispatch/coreDispatch.ts +374 -0
- package/src/tools/dispatch/diagnosticDispatch.ts +101 -0
- package/src/tools/dispatch/directDispatch.ts +167 -0
- package/src/tools/dispatch/goalDispatch.ts +127 -0
- package/src/tools/dispatch/releaseDispatch.ts +65 -0
- package/src/tools/dispatch/types.ts +24 -0
- package/src/tools/evidencePack.ts +490 -0
- package/src/tools/finalizeDirectSession.ts +4 -4
- package/src/tools/goalSubgoalTask.ts +2 -2
- package/src/tools/healthCheck.ts +3 -3
- package/src/tools/listWorkspace.ts +71 -71
- package/src/tools/recommendAgentForTask.ts +79 -0
- package/src/tools/registry.ts +352 -509
- package/src/tools/releaseMode.ts +450 -0
- package/src/tools/retryTask.ts +2 -2
- package/src/tools/runDirectVerificationBundle.ts +98 -0
- package/src/tools/runTaskLoop.ts +526 -0
- package/src/tools/runVerification.ts +8 -0
- package/src/tools/safeViews.ts +4 -2
- package/src/tools/taskLineage.ts +300 -0
- package/src/tools/toolCatalog.ts +6 -0
- package/src/tools/toolRegistry.ts +132 -0
- package/src/version.ts +2 -2
- package/ui/pages/audit.html +192 -3
- package/ui/pages/dashboard.html +1070 -14
- package/ui/pages/direct-sessions.html +505 -53
- package/ui/pages/task-detail.html +456 -438
- package/ui/pages/tasks.html +598 -61
|
@@ -5,6 +5,7 @@ import { join, resolve } from "node:path";
|
|
|
5
5
|
import { tmpdir } from "node:os";
|
|
6
6
|
import { guardDirectPath, guardDirectWritePath, guardDirectReadPath, guardDirectPatchSize, guardDirectFileSize, isBinaryFile } from "../../direct/directGuards.js";
|
|
7
7
|
import { PatchWardenError } from "../../errors.js";
|
|
8
|
+
import { reloadConfig } from "../../config.js";
|
|
8
9
|
import type { PatchWardenConfig } from "../../config.js";
|
|
9
10
|
|
|
10
11
|
function makeConfig(workspaceRoot: string): PatchWardenConfig {
|
|
@@ -199,30 +200,87 @@ describe("guardDirectPatchSize", () => {
|
|
|
199
200
|
|
|
200
201
|
beforeEach(() => {
|
|
201
202
|
tempDir = mkdtempSync(join(tmpdir(), "pw-patchsize-"));
|
|
202
|
-
|
|
203
|
+
// Write a real config file so getConfig() picks it up
|
|
204
|
+
const configPath = join(tempDir, "patchwarden.config.json");
|
|
205
|
+
writeFileSync(configPath, JSON.stringify({
|
|
206
|
+
workspaceRoot: tempDir,
|
|
207
|
+
agents: { codex: { command: "codex", args: ["exec", "{prompt}"] } },
|
|
208
|
+
allowedTestCommands: ["npm test"],
|
|
209
|
+
directMaxPatchBytes: 1000,
|
|
210
|
+
directMaxFileBytes: 5000,
|
|
211
|
+
}), "utf-8");
|
|
212
|
+
process.env.PATCHWARDEN_CONFIG = configPath;
|
|
213
|
+
reloadConfig();
|
|
203
214
|
});
|
|
204
215
|
|
|
205
216
|
afterEach(() => {
|
|
206
217
|
rmSync(tempDir, { recursive: true, force: true });
|
|
207
218
|
delete process.env.PATCHWARDEN_CONFIG;
|
|
219
|
+
reloadConfig();
|
|
208
220
|
});
|
|
209
221
|
|
|
210
222
|
it("allows patches within size limit", () => {
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
// We'll test it indirectly by checking it doesn't throw for small sizes
|
|
214
|
-
// when config is loaded with defaults
|
|
215
|
-
// Skip if no config available
|
|
223
|
+
assert.doesNotThrow(() => guardDirectPatchSize(500));
|
|
224
|
+
assert.doesNotThrow(() => guardDirectPatchSize(1000));
|
|
216
225
|
});
|
|
217
226
|
|
|
218
227
|
it("rejects patches exceeding size limit", () => {
|
|
219
|
-
|
|
228
|
+
assert.throws(
|
|
229
|
+
() => guardDirectPatchSize(1001),
|
|
230
|
+
(err: unknown) => err instanceof PatchWardenError && err.reason === "patch_too_large"
|
|
231
|
+
);
|
|
232
|
+
assert.throws(
|
|
233
|
+
() => guardDirectPatchSize(999999),
|
|
234
|
+
(err: unknown) => err instanceof PatchWardenError && err.reason === "patch_too_large"
|
|
235
|
+
);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("allows zero-size patch", () => {
|
|
239
|
+
assert.doesNotThrow(() => guardDirectPatchSize(0));
|
|
220
240
|
});
|
|
221
241
|
});
|
|
222
242
|
|
|
223
243
|
describe("guardDirectFileSize", () => {
|
|
244
|
+
let tempDir: string;
|
|
245
|
+
|
|
246
|
+
beforeEach(() => {
|
|
247
|
+
tempDir = mkdtempSync(join(tmpdir(), "pw-filesize-"));
|
|
248
|
+
const configPath = join(tempDir, "patchwarden.config.json");
|
|
249
|
+
writeFileSync(configPath, JSON.stringify({
|
|
250
|
+
workspaceRoot: tempDir,
|
|
251
|
+
agents: { codex: { command: "codex", args: ["exec", "{prompt}"] } },
|
|
252
|
+
allowedTestCommands: ["npm test"],
|
|
253
|
+
directMaxPatchBytes: 1000,
|
|
254
|
+
directMaxFileBytes: 5000,
|
|
255
|
+
}), "utf-8");
|
|
256
|
+
process.env.PATCHWARDEN_CONFIG = configPath;
|
|
257
|
+
reloadConfig();
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
afterEach(() => {
|
|
261
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
262
|
+
delete process.env.PATCHWARDEN_CONFIG;
|
|
263
|
+
reloadConfig();
|
|
264
|
+
});
|
|
265
|
+
|
|
224
266
|
it("allows files within size limit", () => {
|
|
225
|
-
|
|
267
|
+
assert.doesNotThrow(() => guardDirectFileSize(100));
|
|
268
|
+
assert.doesNotThrow(() => guardDirectFileSize(5000));
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("rejects files exceeding size limit", () => {
|
|
272
|
+
assert.throws(
|
|
273
|
+
() => guardDirectFileSize(5001),
|
|
274
|
+
(err: unknown) => err instanceof PatchWardenError && err.reason === "file_too_large"
|
|
275
|
+
);
|
|
276
|
+
assert.throws(
|
|
277
|
+
() => guardDirectFileSize(9999999),
|
|
278
|
+
(err: unknown) => err instanceof PatchWardenError && err.reason === "file_too_large"
|
|
279
|
+
);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("allows zero-size file", () => {
|
|
283
|
+
assert.doesNotThrow(() => guardDirectFileSize(0));
|
|
226
284
|
});
|
|
227
285
|
});
|
|
228
286
|
|
|
@@ -294,4 +352,116 @@ describe("isBinaryFile", () => {
|
|
|
294
352
|
it("returns false for non-existent files without binary extension", () => {
|
|
295
353
|
assert.equal(isBinaryFile(join(tempDir, "nonexistent.txt")), false);
|
|
296
354
|
});
|
|
355
|
+
|
|
356
|
+
// ── Adversarial: null byte beyond 8KB read window ──
|
|
357
|
+
it("detects null byte at position 8200 (beyond old 8KB window)", () => {
|
|
358
|
+
// Previously isBinaryFile only read the first 8192 bytes, so a null byte
|
|
359
|
+
// at position 8200 would bypass detection. Now we scan up to 1 MB.
|
|
360
|
+
const stealthBinary = join(tempDir, "stealth.txt");
|
|
361
|
+
const buffer = Buffer.alloc(8200, 0x41); // 8200 bytes of 'A'
|
|
362
|
+
buffer[8199] = 0; // null byte just beyond the old 8KB read window
|
|
363
|
+
writeFileSync(stealthBinary, buffer);
|
|
364
|
+
assert.equal(isBinaryFile(stealthBinary), true);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it("detects null byte at exactly position 8192 (first byte beyond old window)", () => {
|
|
368
|
+
const stealthBinary = join(tempDir, "boundary-plus.txt");
|
|
369
|
+
const buffer = Buffer.alloc(8193, 0x41);
|
|
370
|
+
buffer[8192] = 0; // first byte beyond the old 8KB read window
|
|
371
|
+
writeFileSync(stealthBinary, buffer);
|
|
372
|
+
assert.equal(isBinaryFile(stealthBinary), true);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it("detects null byte at 100KB offset", () => {
|
|
376
|
+
const deepBinary = join(tempDir, "deep.txt");
|
|
377
|
+
const buffer = Buffer.alloc(102400, 0x41);
|
|
378
|
+
buffer[102399] = 0;
|
|
379
|
+
writeFileSync(deepBinary, buffer);
|
|
380
|
+
assert.equal(isBinaryFile(deepBinary), true);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it("detects null byte at 1MB offset (scan limit boundary)", () => {
|
|
384
|
+
const limitBinary = join(tempDir, "limit.txt");
|
|
385
|
+
const buffer = Buffer.alloc(1_048_576, 0x41);
|
|
386
|
+
buffer[1_048_575] = 0;
|
|
387
|
+
writeFileSync(limitBinary, buffer);
|
|
388
|
+
assert.equal(isBinaryFile(limitBinary), true);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it("does not scan beyond 1MB limit", () => {
|
|
392
|
+
// A null byte just past the 1MB scan limit should NOT be detected.
|
|
393
|
+
// This documents the remaining scan boundary.
|
|
394
|
+
const overLimit = join(tempDir, "over-limit.txt");
|
|
395
|
+
const buffer = Buffer.alloc(1_048_577, 0x41);
|
|
396
|
+
buffer[1_048_576] = 0; // first byte beyond scan limit
|
|
397
|
+
writeFileSync(overLimit, buffer);
|
|
398
|
+
assert.equal(isBinaryFile(overLimit), false);
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
// ── Adversarial: Windows backslash prefix bypass attempts ──
|
|
403
|
+
|
|
404
|
+
describe("guardDirectWritePath adversarial separator tests", () => {
|
|
405
|
+
let tempDir: string;
|
|
406
|
+
let repoPath: string;
|
|
407
|
+
|
|
408
|
+
beforeEach(() => {
|
|
409
|
+
tempDir = mkdtempSync(join(tmpdir(), "pw-directsep-"));
|
|
410
|
+
repoPath = join(tempDir, "my-repo");
|
|
411
|
+
mkdirSync(repoPath, { recursive: true });
|
|
412
|
+
mkdirSync(join(repoPath, "src"), { recursive: true });
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
afterEach(() => {
|
|
416
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it("blocks node_modules with backslash prefix", () => {
|
|
420
|
+
assert.throws(
|
|
421
|
+
() => guardDirectWritePath("node_modules\\evil\\index.js", repoPath, tempDir),
|
|
422
|
+
(err: unknown) => err instanceof PatchWardenError && err.reason === "blocked_artifact_path"
|
|
423
|
+
);
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it("blocks dist with backslash prefix", () => {
|
|
427
|
+
assert.throws(
|
|
428
|
+
() => guardDirectWritePath("dist\\main.js", repoPath, tempDir),
|
|
429
|
+
(err: unknown) => err instanceof PatchWardenError && err.reason === "blocked_artifact_path"
|
|
430
|
+
);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it("blocks release with backslash prefix", () => {
|
|
434
|
+
assert.throws(
|
|
435
|
+
() => guardDirectWritePath("release\\app.exe", repoPath, tempDir),
|
|
436
|
+
(err: unknown) => err instanceof PatchWardenError && err.reason === "blocked_artifact_path"
|
|
437
|
+
);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it("blocks nested node_modules with mixed separators", () => {
|
|
441
|
+
assert.throws(
|
|
442
|
+
() => guardDirectWritePath("src\\node_modules/evil\\index.js", repoPath, tempDir),
|
|
443
|
+
(err: unknown) => err instanceof PatchWardenError && err.reason === "blocked_artifact_path"
|
|
444
|
+
);
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it("blocks .patchwarden with backslash prefix", () => {
|
|
448
|
+
assert.throws(
|
|
449
|
+
() => guardDirectWritePath(".patchwarden\\tasks\\evil.json", repoPath, tempDir),
|
|
450
|
+
(err: unknown) => err instanceof PatchWardenError && err.reason === "internal_patchwarden_path_blocked"
|
|
451
|
+
);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it("blocks .env with backslash prefix", () => {
|
|
455
|
+
assert.throws(
|
|
456
|
+
() => guardDirectWritePath("path\\to\\.env", repoPath, tempDir),
|
|
457
|
+
(err: unknown) => err instanceof PatchWardenError && err.reason === "sensitive_path_blocked"
|
|
458
|
+
);
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it("blocks config.json with backslash path", () => {
|
|
462
|
+
assert.throws(
|
|
463
|
+
() => guardDirectWritePath("subdir\\config.json", repoPath, tempDir),
|
|
464
|
+
(err: unknown) => err instanceof PatchWardenError && err.reason === "sensitive_path_blocked"
|
|
465
|
+
);
|
|
466
|
+
});
|
|
297
467
|
});
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
2
|
+
import { strict as assert } from "node:assert";
|
|
3
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { reloadConfig } from "../../config.js";
|
|
7
|
+
import { recommendAgentForTask } from "../../tools/recommendAgentForTask.js";
|
|
8
|
+
import { exportTaskEvidencePack, listEvidencePacks, readEvidencePack } from "../../tools/evidencePack.js";
|
|
9
|
+
import { writeTaskLineage } from "../../tools/taskLineage.js";
|
|
10
|
+
|
|
11
|
+
let tempDir: string;
|
|
12
|
+
let prevConfigEnv: string | undefined;
|
|
13
|
+
|
|
14
|
+
function writeConfig() {
|
|
15
|
+
const configPath = join(tempDir, "patchwarden.config.json");
|
|
16
|
+
writeFileSync(
|
|
17
|
+
configPath,
|
|
18
|
+
JSON.stringify({
|
|
19
|
+
workspaceRoot: tempDir,
|
|
20
|
+
tasksDir: ".patchwarden/tasks",
|
|
21
|
+
plansDir: ".patchwarden/plans",
|
|
22
|
+
assessmentsDir: ".patchwarden/assessments",
|
|
23
|
+
agents: {
|
|
24
|
+
codex: { command: "codex", args: [] },
|
|
25
|
+
opencode: { command: "opencode", args: [] },
|
|
26
|
+
},
|
|
27
|
+
allowedTestCommands: ["npm test", "npm run build"],
|
|
28
|
+
defaultTaskTimeoutSeconds: 30,
|
|
29
|
+
maxTaskTimeoutSeconds: 120,
|
|
30
|
+
}),
|
|
31
|
+
"utf-8"
|
|
32
|
+
);
|
|
33
|
+
prevConfigEnv = process.env.PATCHWARDEN_CONFIG;
|
|
34
|
+
process.env.PATCHWARDEN_CONFIG = configPath;
|
|
35
|
+
reloadConfig();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe("v1.5 evidence packs and agent recommendations", () => {
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
tempDir = mkdtempSync(join(tmpdir(), "pw-evidence-"));
|
|
41
|
+
writeConfig();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
if (prevConfigEnv === undefined) delete process.env.PATCHWARDEN_CONFIG;
|
|
46
|
+
else process.env.PATCHWARDEN_CONFIG = prevConfigEnv;
|
|
47
|
+
reloadConfig();
|
|
48
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("recommends an agent without creating task evidence", () => {
|
|
52
|
+
const recommendation = recommendAgentForTask({
|
|
53
|
+
repo_path: ".",
|
|
54
|
+
goal: "Refactor two modules safely",
|
|
55
|
+
scope_files: ["src/a.ts", "src/b.ts"],
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
assert.equal(recommendation.bounded, true);
|
|
59
|
+
assert.ok(["codex", "opencode"].includes(recommendation.recommended_agent));
|
|
60
|
+
assert.ok(Array.isArray(recommendation.suggested_verify_commands));
|
|
61
|
+
const payload = JSON.stringify(recommendation);
|
|
62
|
+
assert.ok(!payload.includes("stdout"));
|
|
63
|
+
assert.ok(!payload.includes("stderr"));
|
|
64
|
+
assert.ok(!payload.includes("diff"));
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("exports bounded BOM-free evidence pack from lineage", () => {
|
|
68
|
+
writeTaskLineage({
|
|
69
|
+
lineage_id: "lineage_v15_pack",
|
|
70
|
+
goal: "Export evidence",
|
|
71
|
+
repo_path: ".",
|
|
72
|
+
created_at: "2026-07-05T12:00:00.000Z",
|
|
73
|
+
updated_at: "2026-07-05T12:01:00.000Z",
|
|
74
|
+
final_status: "accepted",
|
|
75
|
+
stop_reason: "success",
|
|
76
|
+
next_action: "accept",
|
|
77
|
+
main_task: "task-main",
|
|
78
|
+
fix_tasks: [],
|
|
79
|
+
cleanup_tasks: [],
|
|
80
|
+
direct_sessions: [{
|
|
81
|
+
session_id: "direct-one",
|
|
82
|
+
status: "passed",
|
|
83
|
+
command_count: 1,
|
|
84
|
+
passed_commands: 1,
|
|
85
|
+
failed_commands: 0,
|
|
86
|
+
timed_out_commands: 0,
|
|
87
|
+
audit_decision: "pass",
|
|
88
|
+
changed_files_total: 0,
|
|
89
|
+
next_action: "accept",
|
|
90
|
+
}],
|
|
91
|
+
rounds: [{
|
|
92
|
+
iteration: 1,
|
|
93
|
+
task_id: "task-main",
|
|
94
|
+
role: "main",
|
|
95
|
+
status: "done_by_agent",
|
|
96
|
+
terminal: true,
|
|
97
|
+
verification_status: "passed",
|
|
98
|
+
audit_verdict: "pass",
|
|
99
|
+
fail_checks: [],
|
|
100
|
+
warn_checks: [],
|
|
101
|
+
next_action: "accept",
|
|
102
|
+
}],
|
|
103
|
+
warnings: [],
|
|
104
|
+
errors: [],
|
|
105
|
+
worktree: {
|
|
106
|
+
isolation_mode: "worktree",
|
|
107
|
+
worktree_id: "wt-one",
|
|
108
|
+
worktree_path: join(tempDir, "_workspacetrees", "wt-one"),
|
|
109
|
+
branch: "pw-test",
|
|
110
|
+
cleanup: "keep",
|
|
111
|
+
status: "active",
|
|
112
|
+
next_action: "merge or discard explicitly",
|
|
113
|
+
},
|
|
114
|
+
agent_routing: {
|
|
115
|
+
requested_agent: "auto",
|
|
116
|
+
selected_agent: "codex",
|
|
117
|
+
reason: "test route",
|
|
118
|
+
fallback: false,
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const pack = exportTaskEvidencePack({ lineage_id: "lineage_v15_pack" });
|
|
123
|
+
assert.equal(pack.bounded, true);
|
|
124
|
+
assert.equal(pack.lineage.worktree.worktree_id, "wt-one");
|
|
125
|
+
assert.equal(pack.lineage.agent_routing?.selected_agent, "codex");
|
|
126
|
+
const raw = readFileSync(pack.files.json);
|
|
127
|
+
assert.notEqual(raw[0], 0xef);
|
|
128
|
+
JSON.parse(raw.toString("utf-8"));
|
|
129
|
+
|
|
130
|
+
const payload = JSON.stringify(pack);
|
|
131
|
+
assert.ok(!payload.includes("stdout_tail"));
|
|
132
|
+
assert.ok(!payload.includes("stderr_tail"));
|
|
133
|
+
assert.ok(!payload.includes("diff.patch"));
|
|
134
|
+
assert.ok(!payload.includes("verification.log"));
|
|
135
|
+
|
|
136
|
+
const readBack = readEvidencePack("lineage_v15_pack");
|
|
137
|
+
assert.equal(readBack?.lineage_id, "lineage_v15_pack");
|
|
138
|
+
const listed = listEvidencePacks();
|
|
139
|
+
assert.equal(listed.total, 1);
|
|
140
|
+
assert.equal(listed.evidence_packs[0].lineage_id, "lineage_v15_pack");
|
|
141
|
+
|
|
142
|
+
// v2: all six structured artifact files should exist.
|
|
143
|
+
for (const key of ["risk", "verify", "diffstat", "lineage", "attestation", "redactions"] as const) {
|
|
144
|
+
assert.ok(existsSync(pack.files[key]), `${key} file should exist`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// v2: attestation.json must carry the required provenance fields.
|
|
148
|
+
const attestation = JSON.parse(readFileSync(pack.files.attestation, "utf-8"));
|
|
149
|
+
assert.ok(attestation.patchwarden_version, "attestation should have patchwarden_version");
|
|
150
|
+
assert.ok(attestation.package_version, "attestation should have package_version");
|
|
151
|
+
assert.ok(attestation.commit, "attestation should have commit");
|
|
152
|
+
assert.ok(attestation.node_version, "attestation should have node_version");
|
|
153
|
+
assert.ok(attestation.os, "attestation should have os");
|
|
154
|
+
assert.ok(attestation.schema_epoch, "attestation should have schema_epoch");
|
|
155
|
+
|
|
156
|
+
// v2: redactions.json must have the audit structure even when empty.
|
|
157
|
+
const redactions = JSON.parse(readFileSync(pack.files.redactions, "utf-8"));
|
|
158
|
+
assert.ok(Array.isArray(redactions.redactions), "redactions should be an array");
|
|
159
|
+
assert.equal(typeof redactions.total_redacted, "number", "total_redacted should be a number");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("exports v2 structured artifacts and redacts secrets in diffstat", () => {
|
|
163
|
+
const secretValue = "ghp_1234567890abcdefghijklmnop";
|
|
164
|
+
// Create a task directory with file-stats.json containing a secret in a file path.
|
|
165
|
+
const taskDir = join(tempDir, ".patchwarden", "tasks", "task-v2-secret");
|
|
166
|
+
mkdirSync(taskDir, { recursive: true });
|
|
167
|
+
writeFileSync(join(taskDir, "file-stats.json"), JSON.stringify({
|
|
168
|
+
task_id: "task-v2-secret",
|
|
169
|
+
additions: 5,
|
|
170
|
+
deletions: 2,
|
|
171
|
+
files: [{
|
|
172
|
+
path: `secrets/${secretValue}.json`,
|
|
173
|
+
status: "modified",
|
|
174
|
+
additions: 5,
|
|
175
|
+
deletions: 2,
|
|
176
|
+
}],
|
|
177
|
+
}), "utf-8");
|
|
178
|
+
|
|
179
|
+
writeTaskLineage({
|
|
180
|
+
lineage_id: "lineage_v2_secret",
|
|
181
|
+
goal: "Export v2 evidence with diffstat",
|
|
182
|
+
repo_path: ".",
|
|
183
|
+
created_at: "2026-07-09T12:00:00.000Z",
|
|
184
|
+
updated_at: "2026-07-09T12:01:00.000Z",
|
|
185
|
+
final_status: "accepted",
|
|
186
|
+
stop_reason: "success",
|
|
187
|
+
next_action: "accept",
|
|
188
|
+
main_task: "task-v2-secret",
|
|
189
|
+
fix_tasks: [],
|
|
190
|
+
cleanup_tasks: [],
|
|
191
|
+
direct_sessions: [],
|
|
192
|
+
rounds: [{
|
|
193
|
+
iteration: 1,
|
|
194
|
+
task_id: "task-v2-secret",
|
|
195
|
+
role: "main",
|
|
196
|
+
status: "done_by_agent",
|
|
197
|
+
terminal: true,
|
|
198
|
+
verification_status: "passed",
|
|
199
|
+
audit_verdict: "pass",
|
|
200
|
+
fail_checks: [],
|
|
201
|
+
warn_checks: ["minor scope drift"],
|
|
202
|
+
next_action: "accept",
|
|
203
|
+
}],
|
|
204
|
+
warnings: [],
|
|
205
|
+
errors: [],
|
|
206
|
+
worktree: {
|
|
207
|
+
isolation_mode: "current_repo",
|
|
208
|
+
cleanup: "keep",
|
|
209
|
+
status: "not_used",
|
|
210
|
+
next_action: "none",
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const pack = exportTaskEvidencePack({ lineage_id: "lineage_v2_secret" });
|
|
215
|
+
|
|
216
|
+
// All six v2 files should exist.
|
|
217
|
+
for (const key of ["risk", "verify", "diffstat", "lineage", "attestation", "redactions"] as const) {
|
|
218
|
+
assert.ok(existsSync(pack.files[key]), `${key} file should exist`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// The original secret must not appear in any v2 file.
|
|
222
|
+
for (const key of ["risk", "verify", "diffstat", "lineage", "attestation", "redactions"] as const) {
|
|
223
|
+
const content = readFileSync(pack.files[key], "utf-8");
|
|
224
|
+
assert.ok(!content.includes(secretValue), `${key} should not contain the original secret value`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// redactions.json should have detected the secret via diffstat aggregation.
|
|
228
|
+
const redactions = JSON.parse(readFileSync(pack.files.redactions, "utf-8"));
|
|
229
|
+
assert.ok(redactions.total_redacted > 0, "redactions.json should have detected the secret");
|
|
230
|
+
assert.ok(
|
|
231
|
+
!JSON.stringify(redactions).includes(secretValue),
|
|
232
|
+
"redactions.json should not contain the original secret value"
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
// risk.json should carry the warn_check as a medium-severity risk.
|
|
236
|
+
const risk = JSON.parse(readFileSync(pack.files.risk, "utf-8"));
|
|
237
|
+
assert.ok(risk.count > 0, "risk.json should have at least one risk item");
|
|
238
|
+
assert.equal(risk.by_severity.medium, 1, "should have one medium-severity risk");
|
|
239
|
+
|
|
240
|
+
// verify.json should carry the round verification record.
|
|
241
|
+
const verify = JSON.parse(readFileSync(pack.files.verify, "utf-8"));
|
|
242
|
+
assert.ok(verify.count > 0, "verify.json should have at least one record");
|
|
243
|
+
assert.equal(verify.records[0].verification_status, "passed");
|
|
244
|
+
|
|
245
|
+
// lineage.json should carry the bounded summary.
|
|
246
|
+
const lineageSummary = JSON.parse(readFileSync(pack.files.lineage, "utf-8"));
|
|
247
|
+
assert.equal(lineageSummary.lineage_id, "lineage_v2_secret");
|
|
248
|
+
assert.equal(lineageSummary.iterations_count, 1);
|
|
249
|
+
assert.equal(lineageSummary.final_status, "accepted");
|
|
250
|
+
});
|
|
251
|
+
});
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
2
|
+
import { strict as assert } from "node:assert";
|
|
3
|
+
import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { reloadConfig } from "../../config.js";
|
|
7
|
+
import { getTaskStatus } from "../../tools/getTaskStatus.js";
|
|
8
|
+
|
|
9
|
+
let tempDir: string;
|
|
10
|
+
let tasksDir: string;
|
|
11
|
+
let prevConfigEnv: string | undefined;
|
|
12
|
+
|
|
13
|
+
function writeConfig(): void {
|
|
14
|
+
const configPath = join(tempDir, "patchwarden.config.json");
|
|
15
|
+
writeFileSync(
|
|
16
|
+
configPath,
|
|
17
|
+
JSON.stringify({
|
|
18
|
+
workspaceRoot: tempDir,
|
|
19
|
+
tasksDir: ".patchwarden/tasks",
|
|
20
|
+
plansDir: ".patchwarden/plans",
|
|
21
|
+
assessmentsDir: ".patchwarden/assessments",
|
|
22
|
+
directSessionsDir: ".patchwarden/direct-sessions",
|
|
23
|
+
agents: { codex: { command: "codex", args: ["exec", "{prompt}"] } },
|
|
24
|
+
allowedTestCommands: ["npm test"],
|
|
25
|
+
}),
|
|
26
|
+
"utf-8"
|
|
27
|
+
);
|
|
28
|
+
prevConfigEnv = process.env.PATCHWARDEN_CONFIG;
|
|
29
|
+
process.env.PATCHWARDEN_CONFIG = configPath;
|
|
30
|
+
reloadConfig();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function writeTaskStatus(taskId: string, status: Record<string, unknown>): string {
|
|
34
|
+
const taskDir = join(tasksDir, taskId);
|
|
35
|
+
mkdirSync(taskDir, { recursive: true });
|
|
36
|
+
writeFileSync(join(taskDir, "status.json"), JSON.stringify(status, null, 2), "utf-8");
|
|
37
|
+
return taskDir;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function writeTaskRuntime(taskDir: string, runtime: Record<string, unknown>): void {
|
|
41
|
+
writeFileSync(join(taskDir, "runtime.json"), JSON.stringify(runtime, null, 2), "utf-8");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe("getTaskStatus", () => {
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
tempDir = mkdtempSync(join(tmpdir(), "pw-getstatus-"));
|
|
47
|
+
tasksDir = join(tempDir, ".patchwarden", "tasks");
|
|
48
|
+
mkdirSync(tasksDir, { recursive: true });
|
|
49
|
+
writeConfig();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
afterEach(() => {
|
|
53
|
+
if (prevConfigEnv === undefined) delete process.env.PATCHWARDEN_CONFIG;
|
|
54
|
+
else process.env.PATCHWARDEN_CONFIG = prevConfigEnv;
|
|
55
|
+
reloadConfig();
|
|
56
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// ── pending ──
|
|
60
|
+
it("reads pending status task", () => {
|
|
61
|
+
writeTaskStatus("task-pending-001", {
|
|
62
|
+
task_id: "task-pending-001",
|
|
63
|
+
plan_id: "plan-001",
|
|
64
|
+
agent: "codex",
|
|
65
|
+
workspace_root: tempDir,
|
|
66
|
+
repo_path: "repo",
|
|
67
|
+
resolved_repo_path: join(tempDir, "repo"),
|
|
68
|
+
status: "pending",
|
|
69
|
+
phase: "queued",
|
|
70
|
+
created_at: "2026-07-12T10:00:00Z",
|
|
71
|
+
updated_at: "2026-07-12T10:00:01Z",
|
|
72
|
+
timeout_seconds: 900,
|
|
73
|
+
error: null,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const result = getTaskStatus("task-pending-001");
|
|
77
|
+
|
|
78
|
+
assert.equal(result.task_id, "task-pending-001");
|
|
79
|
+
assert.equal(result.status, "pending");
|
|
80
|
+
assert.equal(result.phase, "queued");
|
|
81
|
+
assert.equal(result.agent, "codex");
|
|
82
|
+
assert.equal(result.plan_id, "plan-001");
|
|
83
|
+
assert.equal(result.error, null);
|
|
84
|
+
// No watcher heartbeat → watcher missing → pending task is execution_blocked
|
|
85
|
+
assert.equal(result.execution_blocked, true);
|
|
86
|
+
assert.equal(result.watcher_status, "missing");
|
|
87
|
+
assert.equal(result.pending_reason, "queued_but_watcher_missing");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// ── running ──
|
|
91
|
+
it("reads running status task and merges runtime.json", () => {
|
|
92
|
+
const taskDir = writeTaskStatus("task-running-001", {
|
|
93
|
+
task_id: "task-running-001",
|
|
94
|
+
plan_id: "plan-001",
|
|
95
|
+
agent: "codex",
|
|
96
|
+
workspace_root: tempDir,
|
|
97
|
+
repo_path: "repo",
|
|
98
|
+
resolved_repo_path: join(tempDir, "repo"),
|
|
99
|
+
status: "running",
|
|
100
|
+
phase: "executing_agent",
|
|
101
|
+
created_at: "2026-07-12T10:00:00Z",
|
|
102
|
+
started_at: "2026-07-12T10:00:05Z",
|
|
103
|
+
updated_at: "2026-07-12T10:00:10Z",
|
|
104
|
+
timeout_seconds: 900,
|
|
105
|
+
error: null,
|
|
106
|
+
});
|
|
107
|
+
writeTaskRuntime(taskDir, {
|
|
108
|
+
phase: "executing_agent",
|
|
109
|
+
last_heartbeat_at: "2026-07-12T10:00:12Z",
|
|
110
|
+
current_command: "codex exec --prompt",
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const result = getTaskStatus("task-running-001");
|
|
114
|
+
|
|
115
|
+
assert.equal(result.status, "running");
|
|
116
|
+
assert.equal(result.phase, "executing_agent");
|
|
117
|
+
assert.equal(result.current_command, "codex exec --prompt");
|
|
118
|
+
assert.equal(result.last_heartbeat_at, "2026-07-12T10:00:12Z");
|
|
119
|
+
assert.equal(result.started_at, "2026-07-12T10:00:05Z");
|
|
120
|
+
// running (not pending) → execution_blocked is false
|
|
121
|
+
assert.equal(result.execution_blocked, false);
|
|
122
|
+
assert.equal(result.pending_reason, "agent_running");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// ── done ──
|
|
126
|
+
it("reads done status task", () => {
|
|
127
|
+
writeTaskStatus("task-done-001", {
|
|
128
|
+
task_id: "task-done-001",
|
|
129
|
+
plan_id: "plan-001",
|
|
130
|
+
agent: "codex",
|
|
131
|
+
workspace_root: tempDir,
|
|
132
|
+
repo_path: "repo",
|
|
133
|
+
resolved_repo_path: join(tempDir, "repo"),
|
|
134
|
+
status: "done",
|
|
135
|
+
phase: "completed",
|
|
136
|
+
created_at: "2026-07-12T10:00:00Z",
|
|
137
|
+
started_at: "2026-07-12T10:00:05Z",
|
|
138
|
+
finished_at: "2026-07-12T10:05:00Z",
|
|
139
|
+
updated_at: "2026-07-12T10:05:00Z",
|
|
140
|
+
timeout_seconds: 900,
|
|
141
|
+
verify_status: "passed",
|
|
142
|
+
error: null,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const result = getTaskStatus("task-done-001");
|
|
146
|
+
|
|
147
|
+
assert.equal(result.status, "done");
|
|
148
|
+
assert.equal(result.phase, "completed");
|
|
149
|
+
assert.equal(result.finished_at, "2026-07-12T10:05:00Z");
|
|
150
|
+
assert.equal(result.verify_status, "passed");
|
|
151
|
+
assert.equal(result.execution_blocked, false);
|
|
152
|
+
assert.equal(result.pending_reason, null);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// ── failed ──
|
|
156
|
+
it("reads failed status task", () => {
|
|
157
|
+
writeTaskStatus("task-failed-001", {
|
|
158
|
+
task_id: "task-failed-001",
|
|
159
|
+
plan_id: "plan-001",
|
|
160
|
+
agent: "codex",
|
|
161
|
+
workspace_root: tempDir,
|
|
162
|
+
repo_path: "repo",
|
|
163
|
+
resolved_repo_path: join(tempDir, "repo"),
|
|
164
|
+
status: "failed",
|
|
165
|
+
phase: "failed",
|
|
166
|
+
created_at: "2026-07-12T10:00:00Z",
|
|
167
|
+
started_at: "2026-07-12T10:00:05Z",
|
|
168
|
+
finished_at: "2026-07-12T10:05:00Z",
|
|
169
|
+
updated_at: "2026-07-12T10:05:00Z",
|
|
170
|
+
timeout_seconds: 900,
|
|
171
|
+
error: "Agent exited with code 1",
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const result = getTaskStatus("task-failed-001");
|
|
175
|
+
|
|
176
|
+
assert.equal(result.status, "failed");
|
|
177
|
+
assert.equal(result.phase, "failed");
|
|
178
|
+
assert.equal(result.error, "Agent exited with code 1");
|
|
179
|
+
assert.equal(result.finished_at, "2026-07-12T10:05:00Z");
|
|
180
|
+
assert.equal(result.execution_blocked, false);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// ── status.json missing ──
|
|
184
|
+
it("throws when status.json is missing (task not found)", () => {
|
|
185
|
+
// guardReadPath throws "File not found" before the existsSync "Task not found" check
|
|
186
|
+
assert.throws(
|
|
187
|
+
() => getTaskStatus("non-existent-task"),
|
|
188
|
+
/(File not found|Task not found)/
|
|
189
|
+
);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// ── status.json corrupt ──
|
|
193
|
+
it("throws when status.json is corrupt", () => {
|
|
194
|
+
const taskDir = join(tasksDir, "task-corrupt-001");
|
|
195
|
+
mkdirSync(taskDir, { recursive: true });
|
|
196
|
+
writeFileSync(join(taskDir, "status.json"), "{not valid json", "utf-8");
|
|
197
|
+
|
|
198
|
+
assert.throws(
|
|
199
|
+
() => getTaskStatus("task-corrupt-001"),
|
|
200
|
+
SyntaxError
|
|
201
|
+
);
|
|
202
|
+
});
|
|
203
|
+
});
|