patchwarden 1.6.1 → 1.6.2

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.
Files changed (91) hide show
  1. package/README.en.md +17 -12
  2. package/README.md +14 -11
  3. package/dist/assessments/assessmentDiagnostics.d.ts +29 -0
  4. package/dist/assessments/assessmentDiagnostics.js +132 -0
  5. package/dist/assessments/assessmentStore.d.ts +39 -0
  6. package/dist/assessments/assessmentStore.js +166 -12
  7. package/dist/assessments/securitySnapshot.d.ts +50 -0
  8. package/dist/assessments/securitySnapshot.js +158 -0
  9. package/dist/control/routes/status.d.ts +14 -0
  10. package/dist/control/routes/status.js +22 -0
  11. package/dist/control/runtime.js +2 -10
  12. package/dist/control/server.js +1 -0
  13. package/dist/diagnostics/allowedTestCommandSafety.d.ts +1 -0
  14. package/dist/diagnostics/allowedTestCommandSafety.js +11 -0
  15. package/dist/direct/directSessionStore.js +5 -4
  16. package/dist/doctor.js +3 -5
  17. package/dist/runner/runTask.d.ts +1 -0
  18. package/dist/runner/runTask.js +47 -6
  19. package/dist/runner/taskRuntime.d.ts +2 -0
  20. package/dist/runner/taskStatusStore.js +12 -1
  21. package/dist/runner/watch.js +64 -1
  22. package/dist/smoke-test.js +3 -3
  23. package/dist/tools/definitions/toolDefs.js +3 -3
  24. package/dist/tools/diagnostics/auditTask.d.ts +1 -0
  25. package/dist/tools/diagnostics/auditTask.js +35 -7
  26. package/dist/tools/diagnostics/healthCheck.d.ts +12 -0
  27. package/dist/tools/diagnostics/healthCheck.js +29 -1
  28. package/dist/tools/tasks/cancelTask.js +2 -1
  29. package/dist/tools/tasks/createTask.d.ts +2 -2
  30. package/dist/tools/tasks/createTask.js +70 -9
  31. package/dist/tools/tasks/diagnoseTask.js +4 -8
  32. package/dist/tools/tasks/getTaskStatus.js +6 -1
  33. package/dist/tools/tasks/getTaskSummary.js +2 -10
  34. package/dist/tools/tasks/listTasks.js +2 -1
  35. package/dist/tools/tasks/reconcileTasks.d.ts +2 -1
  36. package/dist/tools/tasks/reconcileTasks.js +185 -39
  37. package/dist/tools/tasks/runTaskLoop.js +5 -11
  38. package/dist/tools/tasks/taskOutputs.d.ts +2 -2
  39. package/dist/tools/tasks/taskStates.d.ts +6 -0
  40. package/dist/tools/tasks/taskStates.js +52 -0
  41. package/dist/tools/tasks/waitForTask.js +3 -11
  42. package/dist/tools/workspace/listAgents.d.ts +6 -0
  43. package/dist/tools/workspace/listAgents.js +12 -2
  44. package/dist/utils/atomicFile.js +20 -1
  45. package/dist/version.d.ts +1 -1
  46. package/dist/version.js +1 -1
  47. package/docs/CODE_WIKI.md +4 -4
  48. package/docs/open-source-application.md +11 -12
  49. package/docs/release-evidence.md +33 -47
  50. package/package.json +1 -1
  51. package/scripts/checks/control-center-smoke.js +11 -4
  52. package/scripts/checks/lifecycle-smoke.js +5 -2
  53. package/scripts/checks/mcp-smoke.js +31 -1
  54. package/scripts/checks/watcher-supervisor-smoke.js +29 -1
  55. package/scripts/control/start-patchwarden-tunnel.ps1 +65 -1
  56. package/scripts/e2e/demo-runtime.mjs +153 -0
  57. package/scripts/release/desktop-preflight.js +1 -1
  58. package/src/assessments/assessmentDiagnostics.ts +172 -0
  59. package/src/assessments/assessmentStore.ts +237 -12
  60. package/src/assessments/securitySnapshot.ts +231 -0
  61. package/src/control/routes/status.ts +37 -0
  62. package/src/control/runtime.ts +2 -10
  63. package/src/control/server.ts +1 -0
  64. package/src/diagnostics/allowedTestCommandSafety.ts +12 -0
  65. package/src/direct/directSessionStore.ts +6 -4
  66. package/src/doctor.ts +3 -5
  67. package/src/runner/runTask.ts +59 -6
  68. package/src/runner/taskRuntime.ts +2 -0
  69. package/src/runner/taskStatusStore.ts +16 -1
  70. package/src/runner/watch.ts +67 -1
  71. package/src/smoke-test.ts +2 -2
  72. package/src/tools/definitions/toolDefs.ts +3 -3
  73. package/src/tools/diagnostics/auditTask.ts +37 -7
  74. package/src/tools/diagnostics/healthCheck.ts +29 -1
  75. package/src/tools/tasks/cancelTask.ts +2 -1
  76. package/src/tools/tasks/createTask.ts +113 -8
  77. package/src/tools/tasks/diagnoseTask.ts +4 -8
  78. package/src/tools/tasks/getTaskStatus.ts +6 -1
  79. package/src/tools/tasks/getTaskSummary.ts +2 -11
  80. package/src/tools/tasks/listTasks.ts +2 -1
  81. package/src/tools/tasks/reconcileTasks.ts +162 -13
  82. package/src/tools/tasks/runTaskLoop.ts +4 -12
  83. package/src/tools/tasks/taskStates.ts +54 -0
  84. package/src/tools/tasks/waitForTask.ts +3 -12
  85. package/src/tools/workspace/listAgents.ts +17 -3
  86. package/src/utils/atomicFile.ts +17 -1
  87. package/src/version.ts +1 -1
  88. package/ui/pages/dashboard.html +4 -0
  89. package/ui/pages/logs.html +29 -0
  90. package/ui/pages/settings.html +3 -3
  91. package/ui/settings.js +26 -17
@@ -0,0 +1,50 @@
1
+ import type { PatchWardenConfig } from "../config.js";
2
+ import type { ProjectPolicy } from "../policy/projectPolicy.js";
3
+ /**
4
+ * Version the security snapshot independently from the server/schema version.
5
+ * Changing the fields or their canonicalization should invalidate old records.
6
+ */
7
+ export declare const ASSESSMENT_SECURITY_SNAPSHOT_VERSION = "assessment-security-v2";
8
+ export type SecuritySnapshotCategory = "schema_epoch" | "tool_profile" | "tool_manifest" | "workspace_root" | "repo_boundary" | "agent_launch" | "allowed_commands" | "repo_allowed_test_commands" | "direct_allowed_commands" | "repo_direct_allowed_commands" | "sensitive_path_rules" | "protected_paths" | "project_policy" | "risk_rules" | "confirmation_policy" | "task_parameters" | "release_protection" | "direct_profile" | "assessment_ttl" | "timeout_policy";
9
+ export interface AssessmentSecuritySnapshotInput {
10
+ config: PatchWardenConfig;
11
+ schemaEpoch: string;
12
+ toolProfile: string;
13
+ toolManifestSha256: string;
14
+ agent: string;
15
+ repoPath: string;
16
+ changePolicy?: string | null;
17
+ template?: string | null;
18
+ verifyCommands?: string[];
19
+ testCommand?: string | null;
20
+ taskTimeoutSeconds?: number | null;
21
+ scope?: string[];
22
+ forbidden?: string[];
23
+ verification?: string[];
24
+ doneEvidence?: string[];
25
+ riskRulesVersion?: string;
26
+ projectPolicy?: ProjectPolicy | null;
27
+ projectPolicyValid?: boolean | null;
28
+ projectPolicyIssues?: Array<{
29
+ code: string;
30
+ severity: string;
31
+ field: string;
32
+ }>;
33
+ }
34
+ export interface AssessmentSecuritySnapshot {
35
+ assessment_security_snapshot_version: string;
36
+ components: Record<SecuritySnapshotCategory, unknown>;
37
+ }
38
+ export interface AssessmentSecuritySnapshotComparison {
39
+ equal: boolean;
40
+ expected_hash: string;
41
+ actual_hash: string;
42
+ changed_field_names: SecuritySnapshotCategory[];
43
+ }
44
+ /** Build only security-relevant configuration; runtime state is deliberately absent. */
45
+ export declare function buildAssessmentSecuritySnapshot(input: AssessmentSecuritySnapshotInput): AssessmentSecuritySnapshot;
46
+ /** Recursively sort object keys while preserving array order where it matters. */
47
+ export declare function canonicalizeAssessmentSecuritySnapshot(snapshot: AssessmentSecuritySnapshot): AssessmentSecuritySnapshot;
48
+ export declare function hashAssessmentSecuritySnapshot(snapshot: AssessmentSecuritySnapshot): string;
49
+ export declare function getAssessmentSecuritySnapshotComponentHashes(snapshot: AssessmentSecuritySnapshot): Record<SecuritySnapshotCategory, string>;
50
+ export declare function compareAssessmentSecuritySnapshots(expected: AssessmentSecuritySnapshot, actual: AssessmentSecuritySnapshot): AssessmentSecuritySnapshotComparison;
@@ -0,0 +1,158 @@
1
+ import { createHash } from "node:crypto";
2
+ import { resolve, win32 } from "node:path";
3
+ import { getRepoAllowedTestCommands, getRepoDirectAllowedCommands } from "../config.js";
4
+ import { stableJsonStringify } from "../utils/stableJson.js";
5
+ /**
6
+ * Version the security snapshot independently from the server/schema version.
7
+ * Changing the fields or their canonicalization should invalidate old records.
8
+ */
9
+ export const ASSESSMENT_SECURITY_SNAPSHOT_VERSION = "assessment-security-v2";
10
+ function canonicalPath(value) {
11
+ const rawValue = String(value);
12
+ if (/^[a-z]:[\\/]/i.test(rawValue)) {
13
+ return win32.normalize(rawValue).replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
14
+ }
15
+ const normalized = resolve(rawValue).replace(/\\/g, "/").replace(/\/+$/, "");
16
+ return process.platform === "win32" ? normalized.toLowerCase() : normalized;
17
+ }
18
+ function sortedUnique(values) {
19
+ return [...new Set((values || []).map((value) => String(value).trim()))]
20
+ .filter(Boolean)
21
+ .sort((left, right) => left.localeCompare(right));
22
+ }
23
+ function sortedUniqueTaskPaths(values) {
24
+ return sortedUnique(values?.map((value) => {
25
+ const normalized = String(value).replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/{2,}/g, "/");
26
+ return process.platform === "win32" ? normalized.toLowerCase() : normalized;
27
+ }));
28
+ }
29
+ function sortedAgentConfig(config) {
30
+ return Object.fromEntries(Object.entries(config.agents)
31
+ .sort(([left], [right]) => left.localeCompare(right))
32
+ .map(([name, agent]) => [name, {
33
+ command: agent.command,
34
+ args: [...agent.args],
35
+ adapter: agent.adapter || null,
36
+ model: agent.model || null,
37
+ envAllowlist: sortedUnique(agent.envAllowlist),
38
+ }]));
39
+ }
40
+ /** Build only security-relevant configuration; runtime state is deliberately absent. */
41
+ export function buildAssessmentSecuritySnapshot(input) {
42
+ const { config } = input;
43
+ const repoPath = canonicalPath(input.repoPath);
44
+ const components = {
45
+ schema_epoch: input.schemaEpoch,
46
+ tool_profile: input.toolProfile,
47
+ tool_manifest: input.toolManifestSha256,
48
+ workspace_root: canonicalPath(config.workspaceRoot),
49
+ repo_boundary: {
50
+ workspace_root: canonicalPath(config.workspaceRoot),
51
+ repo_path: repoPath,
52
+ repo_aliases: Object.fromEntries(Object.entries(config.repoAliases || {})
53
+ .sort(([left], [right]) => left.localeCompare(right))
54
+ .map(([key, value]) => [key, canonicalPath(resolve(config.workspaceRoot, value))])),
55
+ },
56
+ agent_launch: {
57
+ selected_agent: input.agent,
58
+ configured_agents: sortedAgentConfig(config),
59
+ },
60
+ allowed_commands: {
61
+ configured: sortedUnique(config.allowedTestCommands),
62
+ project: sortedUnique(input.projectPolicy?.allowed_commands),
63
+ },
64
+ repo_allowed_test_commands: sortedUnique(getRepoAllowedTestCommands(config, repoPath)),
65
+ direct_allowed_commands: sortedUnique(config.directAllowedCommands),
66
+ repo_direct_allowed_commands: sortedUnique(getRepoDirectAllowedCommands(config, repoPath)),
67
+ sensitive_path_rules: "sensitive-path-rules-v1",
68
+ protected_paths: sortedUnique(input.projectPolicy?.protected_paths || [
69
+ ".env", ".env.*", ".ssh", ".npmrc", ".pypirc", "patchwarden.config.json",
70
+ ]),
71
+ project_policy: {
72
+ valid: input.projectPolicyValid ?? null,
73
+ effective_policy: input.projectPolicy || null,
74
+ issues: [...(input.projectPolicyIssues || [])]
75
+ .map((issue) => ({ code: issue.code, severity: issue.severity, field: issue.field }))
76
+ .sort((left, right) => stableJsonStringify(left).localeCompare(stableJsonStringify(right))),
77
+ },
78
+ risk_rules: {
79
+ implementation: input.riskRulesVersion || "risk-engine-v1",
80
+ project_high_risk_commands: sortedUnique(input.projectPolicy?.high_risk_commands),
81
+ },
82
+ confirmation_policy: {
83
+ change_policy: input.changePolicy || "repo_scoped_changes",
84
+ template: input.template || null,
85
+ },
86
+ task_parameters: {
87
+ test_command: input.testCommand || null,
88
+ verify_commands: sortedUnique(input.verifyCommands),
89
+ timeout_seconds: input.taskTimeoutSeconds ?? config.defaultTaskTimeoutSeconds,
90
+ scope: sortedUniqueTaskPaths(input.scope),
91
+ forbidden: sortedUniqueTaskPaths(input.forbidden),
92
+ verification: sortedUnique(input.verification),
93
+ done_evidence: sortedUniqueTaskPaths(input.doneEvidence),
94
+ },
95
+ release_protection: {
96
+ high_risk_commands: sortedUnique(input.projectPolicy?.high_risk_commands || [
97
+ "npm publish", "git push", "git tag", "gh release create",
98
+ ]),
99
+ release_mode: input.projectPolicy?.release_mode || {
100
+ version_source: "package.json",
101
+ required_commands: ["npm run build", "npm test"],
102
+ },
103
+ artifact_rules: "artifact-rules-v1",
104
+ },
105
+ direct_profile: Boolean(config.enableDirectProfile),
106
+ assessment_ttl: config.assessmentTtlSeconds,
107
+ timeout_policy: {
108
+ default_task_timeout_seconds: config.defaultTaskTimeoutSeconds,
109
+ max_task_timeout_seconds: config.maxTaskTimeoutSeconds,
110
+ },
111
+ };
112
+ return {
113
+ assessment_security_snapshot_version: ASSESSMENT_SECURITY_SNAPSHOT_VERSION,
114
+ components,
115
+ };
116
+ }
117
+ /** Recursively sort object keys while preserving array order where it matters. */
118
+ export function canonicalizeAssessmentSecuritySnapshot(snapshot) {
119
+ const canonicalize = (value) => {
120
+ if (Array.isArray(value))
121
+ return value.map(canonicalize);
122
+ if (value !== null && typeof value === "object") {
123
+ return Object.fromEntries(Object.entries(value)
124
+ .sort(([left], [right]) => left.localeCompare(right))
125
+ .map(([key, entry]) => [key, canonicalize(entry)]));
126
+ }
127
+ return value;
128
+ };
129
+ return canonicalize(snapshot);
130
+ }
131
+ export function hashAssessmentSecuritySnapshot(snapshot) {
132
+ return createHash("sha256")
133
+ .update(stableJsonStringify(canonicalizeAssessmentSecuritySnapshot(snapshot)))
134
+ .digest("hex");
135
+ }
136
+ export function getAssessmentSecuritySnapshotComponentHashes(snapshot) {
137
+ return Object.fromEntries(Object.entries(snapshot.components).map(([name, value]) => [
138
+ name,
139
+ createHash("sha256").update(stableJsonStringify(value)).digest("hex"),
140
+ ]));
141
+ }
142
+ export function compareAssessmentSecuritySnapshots(expected, actual) {
143
+ const expectedHash = hashAssessmentSecuritySnapshot(expected);
144
+ const actualHash = hashAssessmentSecuritySnapshot(actual);
145
+ const expectedComponents = getAssessmentSecuritySnapshotComponentHashes(expected);
146
+ const actualComponents = getAssessmentSecuritySnapshotComponentHashes(actual);
147
+ const changed = Object.keys(actualComponents)
148
+ .filter((name) => expectedComponents[name] !== actualComponents[name]);
149
+ if (expected.assessment_security_snapshot_version !== actual.assessment_security_snapshot_version) {
150
+ changed.unshift("schema_epoch");
151
+ }
152
+ return {
153
+ equal: expectedHash === actualHash,
154
+ expected_hash: expectedHash,
155
+ actual_hash: actualHash,
156
+ changed_field_names: [...new Set(changed)],
157
+ };
158
+ }
@@ -9,6 +9,20 @@ interface Suggestion {
9
9
  action?: string;
10
10
  link?: string;
11
11
  }
12
+ export interface ConnectionSummary {
13
+ profile: string;
14
+ tool_profile: string | null;
15
+ ready: boolean;
16
+ tool_count: number | null;
17
+ tool_manifest_sha256: string | null;
18
+ tunnel_id_masked: string | null;
19
+ reconnect_guidance: string;
20
+ }
21
+ export declare function buildConnectionSummary(mode: "core" | "direct", tunnel: Record<string, unknown>, tools: {
22
+ tool_profile: string | null;
23
+ tool_count: number | null;
24
+ tool_manifest_sha256: string | null;
25
+ }): ConnectionSummary;
12
26
  interface StatusSnapshotForSuggestions {
13
27
  core: RuntimeHealth;
14
28
  direct: RuntimeHealth;
@@ -13,6 +13,24 @@ import { redactSensitiveValue } from "../../security/contentRedaction.js";
13
13
  import { listAgentsSafe, listTasksForStatus, probeRuntimeHealth, readEvents, recordEvent, readToolManifest, readTunnelStatus, readTunnelUrl, readWatcherStatusSafe, resolveWorkspaceRootSafe, } from "../runtime.js";
14
14
  import { findTunnelClientExecutable } from "./process.js";
15
15
  import { config, configIdentitySha256, controlCenterStatusPath, CORE_BASE_URL, DIRECT_BASE_URL, errorMessage, readJsonFileSafe, sendJson, } from "../shared.js";
16
+ export function buildConnectionSummary(mode, tunnel, tools) {
17
+ const masked = typeof tunnel.tunnel_id_masked === "string" && tunnel.tunnel_id_masked.includes("***")
18
+ ? tunnel.tunnel_id_masked
19
+ : null;
20
+ return {
21
+ profile: typeof tunnel.profile === "string"
22
+ ? tunnel.profile
23
+ : mode === "direct" ? "patchwarden-direct" : "patchwarden",
24
+ tool_profile: tools.tool_profile,
25
+ ready: tunnel.ready === true,
26
+ tool_count: tools.tool_count,
27
+ tool_manifest_sha256: tools.tool_manifest_sha256,
28
+ tunnel_id_masked: masked,
29
+ reconnect_guidance: mode === "direct"
30
+ ? "If ChatGPT still uses an older Direct Tunnel, update the connector binding and test health_check in a new chat."
31
+ : "If ChatGPT still uses an older Core Tunnel, update the connector binding and test health_check in a new chat.",
32
+ };
33
+ }
16
34
  /**
17
35
  * Keep current readiness separate from historical evidence. A month-old failed
18
36
  * task is useful for audit, but must not make a configured local bridge look
@@ -197,6 +215,10 @@ export async function handleStatus(res) {
197
215
  watcher,
198
216
  tunnel: { core: tunnelCore, direct: tunnelDirect },
199
217
  tools: { core: toolsCore, direct: toolsDirect },
218
+ connections: {
219
+ core: buildConnectionSummary("core", tunnelCore, toolsCore),
220
+ direct: buildConnectionSummary("direct", tunnelDirect, toolsDirect),
221
+ },
200
222
  agents,
201
223
  workspace_root: workspaceRoot,
202
224
  tasks,
@@ -19,6 +19,7 @@ import { isValidDirectSessionId as isValidStoredDirectSessionId } from "../direc
19
19
  import { atomicWriteFileSync, atomicWriteJsonFileSync } from "../utils/atomicFile.js";
20
20
  import { withFileLockSync } from "../utils/lockedJsonFile.js";
21
21
  import { logger } from "../logging.js";
22
+ import { TERMINAL_TASK_STATUSES as SHARED_TERMINAL_TASK_STATUSES } from "../tools/tasks/taskStates.js";
22
23
  import { config, controlCenterEventsPath, controlCenterStatusPath, errorMessage, getControlCenterLogDir, getRuntimeRoot, host, MAX_EVENT_LINES, port, readJsonFileSafe, } from "./shared.js";
23
24
  export function probeHealthStatus(targetUrl) {
24
25
  return new Promise((resolve) => {
@@ -158,16 +159,7 @@ export function resolveWorkspaceRootSafe() {
158
159
  return null;
159
160
  }
160
161
  }
161
- export const TERMINAL_TASK_STATUSES = new Set([
162
- "done",
163
- "done_by_agent",
164
- "failed",
165
- "failed_verification",
166
- "failed_scope_violation",
167
- "failed_policy_violation",
168
- "canceled",
169
- "timeout",
170
- ]);
162
+ export const TERMINAL_TASK_STATUSES = SHARED_TERMINAL_TASK_STATUSES;
171
163
  /**
172
164
  * Classify a task as stale based on Phase 2 rules:
173
165
  * - status=running but last_heartbeat_at exceeds threshold
@@ -53,6 +53,7 @@ async function handleRequest(req, res) {
53
53
  (pathname === "/colors_and_type.css" ||
54
54
  pathname === "/desktop.css" ||
55
55
  pathname === "/desktop-bridge.js" ||
56
+ pathname === "/log-parser.js" ||
56
57
  pathname === "/i18n.js" ||
57
58
  pathname === "/getting-started.js" ||
58
59
  pathname === "/settings.js" ||
@@ -0,0 +1 @@
1
+ export declare function isDangerousAllowedTestCommand(command: string): boolean;
@@ -0,0 +1,11 @@
1
+ const DANGEROUS_ALLOWED_TEST_COMMAND_PATTERNS = [
2
+ /(^|\s)rm\s+-rf(?:\s|$)/i,
3
+ /(^|\s)del\s+\/s(?:\s|$)/i,
4
+ /(^|\s)format(?:\.exe)?(?:\s|$)/i,
5
+ /(^|\s)shutdown(?:\.exe)?(?:\s|$)/i,
6
+ /curl\s+[^\r\n|]*\|/i,
7
+ /wget\s+[^\r\n|]*\|/i,
8
+ ];
9
+ export function isDangerousAllowedTestCommand(command) {
10
+ return DANGEROUS_ALLOWED_TEST_COMMAND_PATTERNS.some((pattern) => pattern.test(command));
11
+ }
@@ -9,6 +9,10 @@ import { atomicWriteFileSync, atomicWriteJsonFileSync } from "../utils/atomicFil
9
9
  import { redactSensitiveContent } from "../security/contentRedaction.js";
10
10
  import { mutateLockedJsonFileSync, readJsonObjectFileSync, withFileLock, withFileLockSync, } from "../utils/lockedJsonFile.js";
11
11
  import { getLastToolCatalogSnapshot } from "../tools/catalog/toolCatalog.js";
12
+ // Session history writes are metadata-only and may queue behind concurrent
13
+ // Direct workers. Keep the wait bounded, while workspace mutation locks remain
14
+ // fail-fast so an active patch is never hidden by retrying.
15
+ const DIRECT_SESSION_RECORD_LOCK_WAIT_MS = 10_000;
12
16
  // ── ID generation ──────────────────────────────────────────────────
13
17
  export function generateDirectSessionId() {
14
18
  const timestamp = new Date()
@@ -222,10 +226,7 @@ function mutateDirectSessionRecord(sessionId, mutation, config = getConfig()) {
222
226
  const next = mutation(session);
223
227
  return { next, result: next };
224
228
  }, {
225
- // Session-record appends are short serialized writes. Give Windows
226
- // antivirus-delayed lock detachment enough time to finish instead of
227
- // dropping concurrent operation or verification evidence.
228
- waitMs: 10_000,
229
+ waitMs: DIRECT_SESSION_RECORD_LOCK_WAIT_MS,
229
230
  busyError: () => new PatchWardenError("direct_session_busy", `Direct session "${sessionId}" is currently being updated.`, "Retry after the current Direct operation completes.", true, { session_id: sessionId }),
230
231
  });
231
232
  }
package/dist/doctor.js CHANGED
@@ -25,6 +25,7 @@ import { logger } from "./logging.js";
25
25
  import { unsafeWorkspaceRootLabel } from "./security/workspaceRootGuard.js";
26
26
  import { runSimpleProcessSync } from "./runner/simpleProcess.js";
27
27
  import { resolveAgentLaunch } from "./runner/agentInvocation.js";
28
+ import { isDangerousAllowedTestCommand } from "./diagnostics/allowedTestCommandSafety.js";
28
29
  // ── Helpers ────────────────────────────────────────────────────────
29
30
  function cmd(command, args = []) {
30
31
  try {
@@ -552,12 +553,9 @@ const checkAllowedTestCommandsSafety = {
552
553
  const results = [];
553
554
  const testCmds = context.config.allowedTestCommands || [];
554
555
  results.push(checkResult("allowedTestCommands is non-empty", testCmds.length > 0, testCmds.length > 0 ? `${testCmds.length} commands` : "No test commands configured"));
555
- const dangerous = ["rm -rf", "del /s", "format", "shutdown", "curl |", "wget |"];
556
556
  for (const cmdStr of testCmds) {
557
- for (const danger of dangerous) {
558
- if (cmdStr.toLowerCase().includes(danger)) {
559
- results.push(warnResult(`allowedTestCommands contains dangerous pattern: "${cmdStr}"`));
560
- }
557
+ if (isDangerousAllowedTestCommand(cmdStr)) {
558
+ results.push(warnResult(`allowedTestCommands contains dangerous pattern: "${cmdStr}"`));
561
559
  }
562
560
  }
563
561
  return results;
@@ -5,4 +5,5 @@ interface TaskRunResult {
5
5
  error: string | null;
6
6
  }
7
7
  export declare function runTask(taskId: string): Promise<TaskRunResult>;
8
+ export declare function classifyAgentFailure(stderr: string): string | null;
8
9
  export {};
@@ -1,4 +1,5 @@
1
1
  import { spawn, spawnSync } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
2
3
  import { existsSync, readFileSync, } from "node:fs";
3
4
  import { isAbsolute, join, relative, resolve } from "node:path";
4
5
  import { getTasksDir, getPlansDir, getConfig, resolveWorkspaceRoot } from "../config.js";
@@ -8,6 +9,7 @@ import { writeTaskProgress } from "./taskProgress.js";
8
9
  import { writeTaskRuntime } from "./taskRuntime.js";
9
10
  import { claimPendingTask, readTaskStatusFile, updateTaskStatusFile, } from "./taskStatusStore.js";
10
11
  import { validateAssessmentFreshness } from "../assessments/assessmentStore.js";
12
+ import { recordAssessmentValidationFailure } from "../assessments/assessmentDiagnostics.js";
11
13
  import { buildAgentInvocation, buildExecutionPrompt } from "./agentInvocation.js";
12
14
  import { buildChangeArtifacts, captureRepoSnapshot, compareSnapshots, emptyArtifactHygiene, writeSnapshot, extractExternalDirtyFiles, findNewExternalDirtyFiles, buildArtifactManifest, groupChangedFiles, } from "./changeCapture.js";
13
15
  import { errorPayload } from "../errors.js";
@@ -41,6 +43,7 @@ async function prepareTask(taskId) {
41
43
  if (!existsSync(statusFile))
42
44
  throw new Error(`Task not found: "${taskId}"`);
43
45
  const startedAtMs = Date.now();
46
+ const runnerInstanceId = randomUUID().replace(/-/g, "");
44
47
  const claim = claimPendingTask(statusFile, {
45
48
  status: "running",
46
49
  phase: "preparing",
@@ -93,7 +96,11 @@ async function prepareTask(taskId) {
93
96
  const preExecSnapshot = await captureRepoSnapshot(repoPath);
94
97
  const validation = validateAssessmentFreshness(assessmentId, preExecSnapshot);
95
98
  if (!validation.valid) {
96
- const message = `assessment validation failed: ${validation.failure_reason}. Re-run create_task with execution_mode=assess_only to get a fresh assessment_id.`;
99
+ recordAssessmentValidationFailure(validation);
100
+ const changedFields = validation.config_change_categories?.length
101
+ ? ` Changed fields: ${validation.config_change_categories.join(", ")}.`
102
+ : "";
103
+ const message = `assessment validation failed: ${validation.failure_reason}.${changedFields} Re-run create_task with execution_mode=assess_only to get a fresh assessment_id.`;
97
104
  return failBeforeExecution(taskId, taskDir, message);
98
105
  }
99
106
  }
@@ -106,6 +113,8 @@ async function prepareTask(taskId) {
106
113
  writeTaskRuntime(taskDir, {
107
114
  task_started_at: new Date(startedAtMs).toISOString(),
108
115
  watcher_instance_id: process.env.PATCHWARDEN_WATCHER_INSTANCE_ID || undefined,
116
+ runner_pid: process.pid,
117
+ runner_instance_id: runnerInstanceId,
109
118
  });
110
119
  setTaskPhase(taskDir, "preparing", null, "Capturing pre-task repository state.");
111
120
  let beforeSnapshot;
@@ -125,12 +134,13 @@ async function prepareTask(taskId) {
125
134
  return {
126
135
  taskId, taskDir, statusFile, repoPath, wsRoot, config, plansDir, initialStatus,
127
136
  planId, agentName, testCommand, changePolicy, verifyCommands, timeoutSeconds,
128
- startedAtMs, deadlineMs, beforeSnapshot, beforeWorkspaceSnapshot, externalDirtyBaseline,
137
+ startedAtMs, deadlineMs, runnerInstanceId, beforeSnapshot, beforeWorkspaceSnapshot, externalDirtyBaseline,
129
138
  };
130
139
  }
131
140
  async function executeAgent(ctx) {
132
141
  const state = {
133
142
  agentResult: null,
143
+ agentFailureCategory: null,
134
144
  testResult: skippedTest(ctx.testCommand, ctx.repoPath, "Agent did not complete successfully."),
135
145
  verifyResults: [],
136
146
  finalStatus: "failed",
@@ -154,6 +164,7 @@ async function executeAgent(ctx) {
154
164
  phase: "executing_agent",
155
165
  currentCommand: invocation.commandLabel,
156
166
  deadlineMs: ctx.deadlineMs,
167
+ runnerInstanceId: ctx.runnerInstanceId,
157
168
  stdoutPath: join(ctx.taskDir, "stdout.log"),
158
169
  stderrPath: join(ctx.taskDir, "stderr.log"),
159
170
  environmentVariableNames: invocation.environmentVariableNames,
@@ -167,13 +178,17 @@ async function executeAgent(ctx) {
167
178
  : "Task was canceled by user request.";
168
179
  }
169
180
  else if (agentResult.terminationReason === "timeout") {
181
+ state.finalStatus = "timeout";
170
182
  state.finalError = `Task timed out after ${ctx.timeoutSeconds} seconds during agent execution.`;
171
183
  }
172
184
  else if (agentResult.spawnError) {
173
185
  state.finalError = `Agent spawn failed: ${agentResult.spawnError}`;
174
186
  }
175
187
  else if (agentResult.exitCode !== 0) {
176
- state.finalError = `Agent exited with code ${agentResult.exitCode}.`;
188
+ state.agentFailureCategory = classifyAgentFailure(agentResult.stderr);
189
+ state.finalError = state.agentFailureCategory
190
+ ? `Agent provider failure (${state.agentFailureCategory}); process exited with code ${agentResult.exitCode}.`
191
+ : `Agent exited with code ${agentResult.exitCode}.`;
177
192
  }
178
193
  }
179
194
  catch (error) {
@@ -189,7 +204,7 @@ async function runVerification(ctx, state) {
189
204
  if (ctx.verifyCommands.length > 0) {
190
205
  for (const command of ctx.verifyCommands) {
191
206
  setTaskPhase(ctx.taskDir, "running_tests", command);
192
- const verification = await runTrustedTestCommand(command, ctx.repoPath, ctx.taskDir, ctx.statusFile, ctx.deadlineMs);
207
+ const verification = await runTrustedTestCommand(command, ctx.repoPath, ctx.taskDir, ctx.statusFile, ctx.deadlineMs, ctx.runnerInstanceId);
193
208
  state.verifyResults.push(verification);
194
209
  if (verification.terminationReason || Date.now() >= ctx.deadlineMs)
195
210
  break;
@@ -204,6 +219,7 @@ async function runVerification(ctx, state) {
204
219
  : "Task was canceled during verification.";
205
220
  }
206
221
  else if (interrupted?.terminationReason === "timeout" || Date.now() >= ctx.deadlineMs) {
222
+ state.finalStatus = "timeout";
207
223
  state.finalError = `Task timed out after ${ctx.timeoutSeconds} seconds during verification.`;
208
224
  }
209
225
  else if (failedVerification) {
@@ -403,7 +419,7 @@ function finalizeTask(ctx, state, evidence) {
403
419
  }
404
420
  atomicWriteJsonFileSync(join(ctx.taskDir, "verify.json"), verifyJson);
405
421
  atomicWriteFileSync(join(ctx.taskDir, "verify.log"), buildVerifyLog(verifyJson.commands));
406
- if (!["canceled", "done_by_agent", "failed_verification", "failed_scope_violation", "failed_policy_violation"].includes(state.finalStatus))
422
+ if (!["canceled", "timeout", "done_by_agent", "failed_verification", "failed_scope_violation", "failed_policy_violation"].includes(state.finalStatus))
407
423
  state.finalStatus = "failed";
408
424
  const finalPhase = state.finalStatus === "done_by_agent" ? "done_by_agent" : state.finalStatus;
409
425
  const followup = buildFailureFollowup(state.finalStatus, state.finalError, verifyJson.commands);
@@ -458,6 +474,10 @@ function finalizeTask(ctx, state, evidence) {
458
474
  last_heartbeat_at: finishedAt,
459
475
  finished_at: finishedAt,
460
476
  error: state.finalError,
477
+ termination_reason: state.finalStatus === "timeout"
478
+ ? "timeout"
479
+ : state.finalStatus === "canceled" ? "canceled" : null,
480
+ agent_failure_category: state.agentFailureCategory,
461
481
  changed_files: changes.changed_files.map(({ path, change }) => ({ path, change })),
462
482
  artifact_hygiene_counts: changes.artifact_hygiene.counts,
463
483
  artifact_status: artifactStatus,
@@ -507,6 +527,10 @@ function buildResultJson(input) {
507
527
  template: ctx.initialStatus.template || null,
508
528
  change_policy: ctx.changePolicy,
509
529
  summary: state.finalError || "Agent execution and configured verification completed successfully.",
530
+ termination_reason: state.finalStatus === "timeout"
531
+ ? "timeout"
532
+ : state.finalStatus === "canceled" ? "canceled" : null,
533
+ agent_failure_category: state.agentFailureCategory,
510
534
  changed_files: changes.changed_files,
511
535
  changed_file_groups: {
512
536
  source_changes: changedFileGroups.source_changes.length,
@@ -642,6 +666,9 @@ async function runManagedProcess(options) {
642
666
  writeTaskRuntime(options.taskDir, {
643
667
  child_pid: child.pid,
644
668
  child_started_at: new Date().toISOString(),
669
+ runner_pid: process.pid,
670
+ runner_instance_id: options.runnerInstanceId,
671
+ child_owned_by_runner_instance_id: options.runnerInstanceId,
645
672
  });
646
673
  let stdout = "";
647
674
  let stderr = "";
@@ -757,7 +784,7 @@ async function runManagedProcess(options) {
757
784
  terminationReason,
758
785
  };
759
786
  }
760
- async function runTrustedTestCommand(testCommand, repoPath, taskDir, statusFile, deadlineMs) {
787
+ async function runTrustedTestCommand(testCommand, repoPath, taskDir, statusFile, deadlineMs, runnerInstanceId) {
761
788
  const config = getConfig();
762
789
  const trusted = guardTestCommand(testCommand, config, repoPath);
763
790
  const startedAtMs = Date.now();
@@ -779,6 +806,7 @@ async function runTrustedTestCommand(testCommand, repoPath, taskDir, statusFile,
779
806
  phase: "running_tests",
780
807
  currentCommand: trusted,
781
808
  deadlineMs,
809
+ runnerInstanceId,
782
810
  stdoutPath: join(taskDir, "test.stdout.log"),
783
811
  stderrPath: join(taskDir, "test.stderr.log"),
784
812
  });
@@ -1117,3 +1145,16 @@ function appendBounded(current, next) {
1117
1145
  function errorMessage(error) {
1118
1146
  return error instanceof Error ? error.message : String(error);
1119
1147
  }
1148
+ export function classifyAgentFailure(stderr) {
1149
+ const normalized = stderr.toLowerCase();
1150
+ if (normalized.includes("insufficient balance") || normalized.includes("insufficient credits")) {
1151
+ return "provider_insufficient_balance";
1152
+ }
1153
+ if (normalized.includes("unauthorized") || normalized.includes("authentication failed") || normalized.includes("invalid api key")) {
1154
+ return "provider_authentication_failed";
1155
+ }
1156
+ if (normalized.includes("model not found") || normalized.includes("model access") || normalized.includes("permission denied")) {
1157
+ return "provider_model_unavailable";
1158
+ }
1159
+ return null;
1160
+ }
@@ -4,6 +4,8 @@ export interface TaskRuntimeData {
4
4
  last_heartbeat_at: string;
5
5
  current_command: string | null;
6
6
  runner_pid?: number;
7
+ runner_instance_id?: string;
8
+ child_owned_by_runner_instance_id?: string;
7
9
  child_pid?: number;
8
10
  /**
9
11
  * v0.7.0: ISO timestamp when the child process was spawned.
@@ -1,10 +1,21 @@
1
1
  import { PatchWardenError } from "../errors.js";
2
2
  import { mutateLockedJsonFileSync, readJsonObjectFileSync, } from "../utils/lockedJsonFile.js";
3
+ import { isAllowedTaskStatusTransition } from "../tools/tasks/taskStates.js";
3
4
  export function readTaskStatusFile(statusFile) {
4
5
  return readJsonObjectFileSync(statusFile);
5
6
  }
6
7
  export function mutateTaskStatus(statusFile, mutation) {
7
- return mutateLockedJsonFileSync(statusFile, mutation, {
8
+ return mutateLockedJsonFileSync(statusFile, (current) => {
9
+ const outcome = mutation(current);
10
+ if (outcome.next) {
11
+ const from = String(current.status || "");
12
+ const to = String(outcome.next.status || from);
13
+ if (!isAllowedTaskStatusTransition(from, to)) {
14
+ throw new PatchWardenError("invalid_task_status_transition", `Task status cannot transition from "${from || "unknown"}" to "${to || "unknown"}".`, "Refresh the task status and apply only a forward lifecycle transition.");
15
+ }
16
+ }
17
+ return outcome;
18
+ }, {
8
19
  busyError: () => new PatchWardenError("task_status_busy", "Task status is currently being updated by another PatchWarden process.", "Retry the operation after the current task status update completes.", true),
9
20
  });
10
21
  }