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.
- package/README.en.md +17 -12
- package/README.md +14 -11
- package/dist/assessments/assessmentDiagnostics.d.ts +29 -0
- package/dist/assessments/assessmentDiagnostics.js +132 -0
- package/dist/assessments/assessmentStore.d.ts +39 -0
- package/dist/assessments/assessmentStore.js +166 -12
- package/dist/assessments/securitySnapshot.d.ts +50 -0
- package/dist/assessments/securitySnapshot.js +158 -0
- package/dist/control/routes/status.d.ts +14 -0
- package/dist/control/routes/status.js +22 -0
- package/dist/control/runtime.js +2 -10
- package/dist/control/server.js +1 -0
- package/dist/diagnostics/allowedTestCommandSafety.d.ts +1 -0
- package/dist/diagnostics/allowedTestCommandSafety.js +11 -0
- package/dist/direct/directSessionStore.js +5 -4
- package/dist/doctor.js +3 -5
- package/dist/runner/runTask.d.ts +1 -0
- package/dist/runner/runTask.js +47 -6
- package/dist/runner/taskRuntime.d.ts +2 -0
- package/dist/runner/taskStatusStore.js +12 -1
- package/dist/runner/watch.js +64 -1
- package/dist/smoke-test.js +3 -3
- package/dist/tools/definitions/toolDefs.js +3 -3
- package/dist/tools/diagnostics/auditTask.d.ts +1 -0
- package/dist/tools/diagnostics/auditTask.js +35 -7
- package/dist/tools/diagnostics/healthCheck.d.ts +12 -0
- package/dist/tools/diagnostics/healthCheck.js +29 -1
- package/dist/tools/tasks/cancelTask.js +2 -1
- package/dist/tools/tasks/createTask.d.ts +2 -2
- package/dist/tools/tasks/createTask.js +70 -9
- package/dist/tools/tasks/diagnoseTask.js +4 -8
- package/dist/tools/tasks/getTaskStatus.js +6 -1
- package/dist/tools/tasks/getTaskSummary.js +2 -10
- package/dist/tools/tasks/listTasks.js +2 -1
- package/dist/tools/tasks/reconcileTasks.d.ts +2 -1
- package/dist/tools/tasks/reconcileTasks.js +185 -39
- package/dist/tools/tasks/runTaskLoop.js +5 -11
- package/dist/tools/tasks/taskOutputs.d.ts +2 -2
- package/dist/tools/tasks/taskStates.d.ts +6 -0
- package/dist/tools/tasks/taskStates.js +52 -0
- package/dist/tools/tasks/waitForTask.js +3 -11
- package/dist/tools/workspace/listAgents.d.ts +6 -0
- package/dist/tools/workspace/listAgents.js +12 -2
- package/dist/utils/atomicFile.js +20 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/docs/CODE_WIKI.md +4 -4
- package/docs/open-source-application.md +11 -12
- package/docs/release-evidence.md +33 -47
- package/package.json +1 -1
- package/scripts/checks/control-center-smoke.js +11 -4
- package/scripts/checks/lifecycle-smoke.js +5 -2
- package/scripts/checks/mcp-smoke.js +31 -1
- package/scripts/checks/watcher-supervisor-smoke.js +29 -1
- package/scripts/control/start-patchwarden-tunnel.ps1 +65 -1
- package/scripts/e2e/demo-runtime.mjs +153 -0
- package/scripts/release/desktop-preflight.js +1 -1
- package/src/assessments/assessmentDiagnostics.ts +172 -0
- package/src/assessments/assessmentStore.ts +237 -12
- package/src/assessments/securitySnapshot.ts +231 -0
- package/src/control/routes/status.ts +37 -0
- package/src/control/runtime.ts +2 -10
- package/src/control/server.ts +1 -0
- package/src/diagnostics/allowedTestCommandSafety.ts +12 -0
- package/src/direct/directSessionStore.ts +6 -4
- package/src/doctor.ts +3 -5
- package/src/runner/runTask.ts +59 -6
- package/src/runner/taskRuntime.ts +2 -0
- package/src/runner/taskStatusStore.ts +16 -1
- package/src/runner/watch.ts +67 -1
- package/src/smoke-test.ts +2 -2
- package/src/tools/definitions/toolDefs.ts +3 -3
- package/src/tools/diagnostics/auditTask.ts +37 -7
- package/src/tools/diagnostics/healthCheck.ts +29 -1
- package/src/tools/tasks/cancelTask.ts +2 -1
- package/src/tools/tasks/createTask.ts +113 -8
- package/src/tools/tasks/diagnoseTask.ts +4 -8
- package/src/tools/tasks/getTaskStatus.ts +6 -1
- package/src/tools/tasks/getTaskSummary.ts +2 -11
- package/src/tools/tasks/listTasks.ts +2 -1
- package/src/tools/tasks/reconcileTasks.ts +162 -13
- package/src/tools/tasks/runTaskLoop.ts +4 -12
- package/src/tools/tasks/taskStates.ts +54 -0
- package/src/tools/tasks/waitForTask.ts +3 -12
- package/src/tools/workspace/listAgents.ts +17 -3
- package/src/utils/atomicFile.ts +17 -1
- package/src/version.ts +1 -1
- package/ui/pages/dashboard.html +4 -0
- package/ui/pages/logs.html +29 -0
- package/ui/pages/settings.html +3 -3
- package/ui/settings.js +26 -17
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { resolve, win32 } from "node:path";
|
|
3
|
+
import type { PatchWardenConfig } from "../config.js";
|
|
4
|
+
import { getRepoAllowedTestCommands, getRepoDirectAllowedCommands } from "../config.js";
|
|
5
|
+
import { stableJsonStringify } from "../utils/stableJson.js";
|
|
6
|
+
import type { ProjectPolicy } from "../policy/projectPolicy.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Version the security snapshot independently from the server/schema version.
|
|
10
|
+
* Changing the fields or their canonicalization should invalidate old records.
|
|
11
|
+
*/
|
|
12
|
+
export const ASSESSMENT_SECURITY_SNAPSHOT_VERSION = "assessment-security-v2";
|
|
13
|
+
|
|
14
|
+
export type SecuritySnapshotCategory =
|
|
15
|
+
| "schema_epoch"
|
|
16
|
+
| "tool_profile"
|
|
17
|
+
| "tool_manifest"
|
|
18
|
+
| "workspace_root"
|
|
19
|
+
| "repo_boundary"
|
|
20
|
+
| "agent_launch"
|
|
21
|
+
| "allowed_commands"
|
|
22
|
+
| "repo_allowed_test_commands"
|
|
23
|
+
| "direct_allowed_commands"
|
|
24
|
+
| "repo_direct_allowed_commands"
|
|
25
|
+
| "sensitive_path_rules"
|
|
26
|
+
| "protected_paths"
|
|
27
|
+
| "project_policy"
|
|
28
|
+
| "risk_rules"
|
|
29
|
+
| "confirmation_policy"
|
|
30
|
+
| "task_parameters"
|
|
31
|
+
| "release_protection"
|
|
32
|
+
| "direct_profile"
|
|
33
|
+
| "assessment_ttl"
|
|
34
|
+
| "timeout_policy";
|
|
35
|
+
|
|
36
|
+
export interface AssessmentSecuritySnapshotInput {
|
|
37
|
+
config: PatchWardenConfig;
|
|
38
|
+
schemaEpoch: string;
|
|
39
|
+
toolProfile: string;
|
|
40
|
+
toolManifestSha256: string;
|
|
41
|
+
agent: string;
|
|
42
|
+
repoPath: string;
|
|
43
|
+
changePolicy?: string | null;
|
|
44
|
+
template?: string | null;
|
|
45
|
+
verifyCommands?: string[];
|
|
46
|
+
testCommand?: string | null;
|
|
47
|
+
taskTimeoutSeconds?: number | null;
|
|
48
|
+
scope?: string[];
|
|
49
|
+
forbidden?: string[];
|
|
50
|
+
verification?: string[];
|
|
51
|
+
doneEvidence?: string[];
|
|
52
|
+
riskRulesVersion?: string;
|
|
53
|
+
projectPolicy?: ProjectPolicy | null;
|
|
54
|
+
projectPolicyValid?: boolean | null;
|
|
55
|
+
projectPolicyIssues?: Array<{ code: string; severity: string; field: string }>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface AssessmentSecuritySnapshot {
|
|
59
|
+
assessment_security_snapshot_version: string;
|
|
60
|
+
components: Record<SecuritySnapshotCategory, unknown>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface AssessmentSecuritySnapshotComparison {
|
|
64
|
+
equal: boolean;
|
|
65
|
+
expected_hash: string;
|
|
66
|
+
actual_hash: string;
|
|
67
|
+
changed_field_names: SecuritySnapshotCategory[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function canonicalPath(value: string): string {
|
|
71
|
+
const rawValue = String(value);
|
|
72
|
+
if (/^[a-z]:[\\/]/i.test(rawValue)) {
|
|
73
|
+
return win32.normalize(rawValue).replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
|
|
74
|
+
}
|
|
75
|
+
const normalized = resolve(rawValue).replace(/\\/g, "/").replace(/\/+$/, "");
|
|
76
|
+
return process.platform === "win32" ? normalized.toLowerCase() : normalized;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function sortedUnique(values: readonly string[] | undefined): string[] {
|
|
80
|
+
return [...new Set((values || []).map((value) => String(value).trim()))]
|
|
81
|
+
.filter(Boolean)
|
|
82
|
+
.sort((left, right) => left.localeCompare(right));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function sortedUniqueTaskPaths(values: readonly string[] | undefined): string[] {
|
|
86
|
+
return sortedUnique(values?.map((value) => {
|
|
87
|
+
const normalized = String(value).replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/{2,}/g, "/");
|
|
88
|
+
return process.platform === "win32" ? normalized.toLowerCase() : normalized;
|
|
89
|
+
}));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function sortedAgentConfig(config: PatchWardenConfig): Record<string, unknown> {
|
|
93
|
+
return Object.fromEntries(Object.entries(config.agents)
|
|
94
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
95
|
+
.map(([name, agent]) => [name, {
|
|
96
|
+
command: agent.command,
|
|
97
|
+
args: [...agent.args],
|
|
98
|
+
adapter: agent.adapter || null,
|
|
99
|
+
model: agent.model || null,
|
|
100
|
+
envAllowlist: sortedUnique(agent.envAllowlist),
|
|
101
|
+
}]));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Build only security-relevant configuration; runtime state is deliberately absent. */
|
|
105
|
+
export function buildAssessmentSecuritySnapshot(input: AssessmentSecuritySnapshotInput): AssessmentSecuritySnapshot {
|
|
106
|
+
const { config } = input;
|
|
107
|
+
const repoPath = canonicalPath(input.repoPath);
|
|
108
|
+
const components: Record<SecuritySnapshotCategory, unknown> = {
|
|
109
|
+
schema_epoch: input.schemaEpoch,
|
|
110
|
+
tool_profile: input.toolProfile,
|
|
111
|
+
tool_manifest: input.toolManifestSha256,
|
|
112
|
+
workspace_root: canonicalPath(config.workspaceRoot),
|
|
113
|
+
repo_boundary: {
|
|
114
|
+
workspace_root: canonicalPath(config.workspaceRoot),
|
|
115
|
+
repo_path: repoPath,
|
|
116
|
+
repo_aliases: Object.fromEntries(Object.entries(config.repoAliases || {})
|
|
117
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
118
|
+
.map(([key, value]) => [key, canonicalPath(resolve(config.workspaceRoot, value))])),
|
|
119
|
+
},
|
|
120
|
+
agent_launch: {
|
|
121
|
+
selected_agent: input.agent,
|
|
122
|
+
configured_agents: sortedAgentConfig(config),
|
|
123
|
+
},
|
|
124
|
+
allowed_commands: {
|
|
125
|
+
configured: sortedUnique(config.allowedTestCommands),
|
|
126
|
+
project: sortedUnique(input.projectPolicy?.allowed_commands),
|
|
127
|
+
},
|
|
128
|
+
repo_allowed_test_commands: sortedUnique(getRepoAllowedTestCommands(config, repoPath)),
|
|
129
|
+
direct_allowed_commands: sortedUnique(config.directAllowedCommands),
|
|
130
|
+
repo_direct_allowed_commands: sortedUnique(getRepoDirectAllowedCommands(config, repoPath)),
|
|
131
|
+
sensitive_path_rules: "sensitive-path-rules-v1",
|
|
132
|
+
protected_paths: sortedUnique(input.projectPolicy?.protected_paths || [
|
|
133
|
+
".env", ".env.*", ".ssh", ".npmrc", ".pypirc", "patchwarden.config.json",
|
|
134
|
+
]),
|
|
135
|
+
project_policy: {
|
|
136
|
+
valid: input.projectPolicyValid ?? null,
|
|
137
|
+
effective_policy: input.projectPolicy || null,
|
|
138
|
+
issues: [...(input.projectPolicyIssues || [])]
|
|
139
|
+
.map((issue) => ({ code: issue.code, severity: issue.severity, field: issue.field }))
|
|
140
|
+
.sort((left, right) => stableJsonStringify(left).localeCompare(stableJsonStringify(right))),
|
|
141
|
+
},
|
|
142
|
+
risk_rules: {
|
|
143
|
+
implementation: input.riskRulesVersion || "risk-engine-v1",
|
|
144
|
+
project_high_risk_commands: sortedUnique(input.projectPolicy?.high_risk_commands),
|
|
145
|
+
},
|
|
146
|
+
confirmation_policy: {
|
|
147
|
+
change_policy: input.changePolicy || "repo_scoped_changes",
|
|
148
|
+
template: input.template || null,
|
|
149
|
+
},
|
|
150
|
+
task_parameters: {
|
|
151
|
+
test_command: input.testCommand || null,
|
|
152
|
+
verify_commands: sortedUnique(input.verifyCommands),
|
|
153
|
+
timeout_seconds: input.taskTimeoutSeconds ?? config.defaultTaskTimeoutSeconds,
|
|
154
|
+
scope: sortedUniqueTaskPaths(input.scope),
|
|
155
|
+
forbidden: sortedUniqueTaskPaths(input.forbidden),
|
|
156
|
+
verification: sortedUnique(input.verification),
|
|
157
|
+
done_evidence: sortedUniqueTaskPaths(input.doneEvidence),
|
|
158
|
+
},
|
|
159
|
+
release_protection: {
|
|
160
|
+
high_risk_commands: sortedUnique(input.projectPolicy?.high_risk_commands || [
|
|
161
|
+
"npm publish", "git push", "git tag", "gh release create",
|
|
162
|
+
]),
|
|
163
|
+
release_mode: input.projectPolicy?.release_mode || {
|
|
164
|
+
version_source: "package.json",
|
|
165
|
+
required_commands: ["npm run build", "npm test"],
|
|
166
|
+
},
|
|
167
|
+
artifact_rules: "artifact-rules-v1",
|
|
168
|
+
},
|
|
169
|
+
direct_profile: Boolean(config.enableDirectProfile),
|
|
170
|
+
assessment_ttl: config.assessmentTtlSeconds,
|
|
171
|
+
timeout_policy: {
|
|
172
|
+
default_task_timeout_seconds: config.defaultTaskTimeoutSeconds,
|
|
173
|
+
max_task_timeout_seconds: config.maxTaskTimeoutSeconds,
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
assessment_security_snapshot_version: ASSESSMENT_SECURITY_SNAPSHOT_VERSION,
|
|
179
|
+
components,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Recursively sort object keys while preserving array order where it matters. */
|
|
184
|
+
export function canonicalizeAssessmentSecuritySnapshot(snapshot: AssessmentSecuritySnapshot): AssessmentSecuritySnapshot {
|
|
185
|
+
const canonicalize = (value: unknown): unknown => {
|
|
186
|
+
if (Array.isArray(value)) return value.map(canonicalize);
|
|
187
|
+
if (value !== null && typeof value === "object") {
|
|
188
|
+
return Object.fromEntries(Object.entries(value as Record<string, unknown>)
|
|
189
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
190
|
+
.map(([key, entry]) => [key, canonicalize(entry)]));
|
|
191
|
+
}
|
|
192
|
+
return value;
|
|
193
|
+
};
|
|
194
|
+
return canonicalize(snapshot) as AssessmentSecuritySnapshot;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function hashAssessmentSecuritySnapshot(snapshot: AssessmentSecuritySnapshot): string {
|
|
198
|
+
return createHash("sha256")
|
|
199
|
+
.update(stableJsonStringify(canonicalizeAssessmentSecuritySnapshot(snapshot)))
|
|
200
|
+
.digest("hex");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function getAssessmentSecuritySnapshotComponentHashes(
|
|
204
|
+
snapshot: AssessmentSecuritySnapshot,
|
|
205
|
+
): Record<SecuritySnapshotCategory, string> {
|
|
206
|
+
return Object.fromEntries(Object.entries(snapshot.components).map(([name, value]) => [
|
|
207
|
+
name,
|
|
208
|
+
createHash("sha256").update(stableJsonStringify(value)).digest("hex"),
|
|
209
|
+
])) as Record<SecuritySnapshotCategory, string>;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function compareAssessmentSecuritySnapshots(
|
|
213
|
+
expected: AssessmentSecuritySnapshot,
|
|
214
|
+
actual: AssessmentSecuritySnapshot,
|
|
215
|
+
): AssessmentSecuritySnapshotComparison {
|
|
216
|
+
const expectedHash = hashAssessmentSecuritySnapshot(expected);
|
|
217
|
+
const actualHash = hashAssessmentSecuritySnapshot(actual);
|
|
218
|
+
const expectedComponents = getAssessmentSecuritySnapshotComponentHashes(expected);
|
|
219
|
+
const actualComponents = getAssessmentSecuritySnapshotComponentHashes(actual);
|
|
220
|
+
const changed = (Object.keys(actualComponents) as SecuritySnapshotCategory[])
|
|
221
|
+
.filter((name) => expectedComponents[name] !== actualComponents[name]);
|
|
222
|
+
if (expected.assessment_security_snapshot_version !== actual.assessment_security_snapshot_version) {
|
|
223
|
+
changed.unshift("schema_epoch");
|
|
224
|
+
}
|
|
225
|
+
return {
|
|
226
|
+
equal: expectedHash === actualHash,
|
|
227
|
+
expected_hash: expectedHash,
|
|
228
|
+
actual_hash: actualHash,
|
|
229
|
+
changed_field_names: [...new Set(changed)],
|
|
230
|
+
};
|
|
231
|
+
}
|
|
@@ -52,6 +52,39 @@ interface Suggestion {
|
|
|
52
52
|
link?: string;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
export interface ConnectionSummary {
|
|
56
|
+
profile: string;
|
|
57
|
+
tool_profile: string | null;
|
|
58
|
+
ready: boolean;
|
|
59
|
+
tool_count: number | null;
|
|
60
|
+
tool_manifest_sha256: string | null;
|
|
61
|
+
tunnel_id_masked: string | null;
|
|
62
|
+
reconnect_guidance: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function buildConnectionSummary(
|
|
66
|
+
mode: "core" | "direct",
|
|
67
|
+
tunnel: Record<string, unknown>,
|
|
68
|
+
tools: { tool_profile: string | null; tool_count: number | null; tool_manifest_sha256: string | null },
|
|
69
|
+
): ConnectionSummary {
|
|
70
|
+
const masked = typeof tunnel.tunnel_id_masked === "string" && tunnel.tunnel_id_masked.includes("***")
|
|
71
|
+
? tunnel.tunnel_id_masked
|
|
72
|
+
: null;
|
|
73
|
+
return {
|
|
74
|
+
profile: typeof tunnel.profile === "string"
|
|
75
|
+
? tunnel.profile
|
|
76
|
+
: mode === "direct" ? "patchwarden-direct" : "patchwarden",
|
|
77
|
+
tool_profile: tools.tool_profile,
|
|
78
|
+
ready: tunnel.ready === true,
|
|
79
|
+
tool_count: tools.tool_count,
|
|
80
|
+
tool_manifest_sha256: tools.tool_manifest_sha256,
|
|
81
|
+
tunnel_id_masked: masked,
|
|
82
|
+
reconnect_guidance: mode === "direct"
|
|
83
|
+
? "If ChatGPT still uses an older Direct Tunnel, update the connector binding and test health_check in a new chat."
|
|
84
|
+
: "If ChatGPT still uses an older Core Tunnel, update the connector binding and test health_check in a new chat.",
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
55
88
|
interface StatusSnapshotForSuggestions {
|
|
56
89
|
core: RuntimeHealth;
|
|
57
90
|
direct: RuntimeHealth;
|
|
@@ -290,6 +323,10 @@ export async function handleStatus(res: ServerResponse): Promise<void> {
|
|
|
290
323
|
watcher,
|
|
291
324
|
tunnel: { core: tunnelCore, direct: tunnelDirect },
|
|
292
325
|
tools: { core: toolsCore, direct: toolsDirect },
|
|
326
|
+
connections: {
|
|
327
|
+
core: buildConnectionSummary("core", tunnelCore, toolsCore),
|
|
328
|
+
direct: buildConnectionSummary("direct", tunnelDirect, toolsDirect),
|
|
329
|
+
},
|
|
293
330
|
agents,
|
|
294
331
|
workspace_root: workspaceRoot,
|
|
295
332
|
tasks,
|
package/src/control/runtime.ts
CHANGED
|
@@ -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 {
|
|
23
24
|
config,
|
|
24
25
|
controlCenterEventsPath,
|
|
@@ -204,16 +205,7 @@ export interface StaleClassification {
|
|
|
204
205
|
stale_reasons: string[];
|
|
205
206
|
}
|
|
206
207
|
|
|
207
|
-
export const TERMINAL_TASK_STATUSES =
|
|
208
|
-
"done",
|
|
209
|
-
"done_by_agent",
|
|
210
|
-
"failed",
|
|
211
|
-
"failed_verification",
|
|
212
|
-
"failed_scope_violation",
|
|
213
|
-
"failed_policy_violation",
|
|
214
|
-
"canceled",
|
|
215
|
-
"timeout",
|
|
216
|
-
]);
|
|
208
|
+
export const TERMINAL_TASK_STATUSES = SHARED_TERMINAL_TASK_STATUSES;
|
|
217
209
|
|
|
218
210
|
/**
|
|
219
211
|
* Classify a task as stale based on Phase 2 rules:
|
package/src/control/server.ts
CHANGED
|
@@ -68,6 +68,7 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise
|
|
|
68
68
|
(pathname === "/colors_and_type.css" ||
|
|
69
69
|
pathname === "/desktop.css" ||
|
|
70
70
|
pathname === "/desktop-bridge.js" ||
|
|
71
|
+
pathname === "/log-parser.js" ||
|
|
71
72
|
pathname === "/i18n.js" ||
|
|
72
73
|
pathname === "/getting-started.js" ||
|
|
73
74
|
pathname === "/settings.js" ||
|
|
@@ -0,0 +1,12 @@
|
|
|
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
|
+
|
|
10
|
+
export function isDangerousAllowedTestCommand(command: string): boolean {
|
|
11
|
+
return DANGEROUS_ALLOWED_TEST_COMMAND_PATTERNS.some((pattern) => pattern.test(command));
|
|
12
|
+
}
|
|
@@ -27,6 +27,11 @@ import {
|
|
|
27
27
|
type ChangeArtifacts,
|
|
28
28
|
} from "../runner/changeCapture.js";
|
|
29
29
|
|
|
30
|
+
// Session history writes are metadata-only and may queue behind concurrent
|
|
31
|
+
// Direct workers. Keep the wait bounded, while workspace mutation locks remain
|
|
32
|
+
// fail-fast so an active patch is never hidden by retrying.
|
|
33
|
+
const DIRECT_SESSION_RECORD_LOCK_WAIT_MS = 10_000;
|
|
34
|
+
|
|
30
35
|
// ── Types ──────────────────────────────────────────────────────────
|
|
31
36
|
|
|
32
37
|
export interface DirectSessionOperation {
|
|
@@ -423,10 +428,7 @@ function mutateDirectSessionRecord(
|
|
|
423
428
|
return { next, result: next };
|
|
424
429
|
},
|
|
425
430
|
{
|
|
426
|
-
|
|
427
|
-
// antivirus-delayed lock detachment enough time to finish instead of
|
|
428
|
-
// dropping concurrent operation or verification evidence.
|
|
429
|
-
waitMs: 10_000,
|
|
431
|
+
waitMs: DIRECT_SESSION_RECORD_LOCK_WAIT_MS,
|
|
430
432
|
busyError: () => new PatchWardenError(
|
|
431
433
|
"direct_session_busy",
|
|
432
434
|
`Direct session "${sessionId}" is currently being updated.`,
|
package/src/doctor.ts
CHANGED
|
@@ -26,6 +26,7 @@ import { logger } from "./logging.js";
|
|
|
26
26
|
import { unsafeWorkspaceRootLabel } from "./security/workspaceRootGuard.js";
|
|
27
27
|
import { runSimpleProcessSync } from "./runner/simpleProcess.js";
|
|
28
28
|
import { resolveAgentLaunch } from "./runner/agentInvocation.js";
|
|
29
|
+
import { isDangerousAllowedTestCommand } from "./diagnostics/allowedTestCommandSafety.js";
|
|
29
30
|
|
|
30
31
|
// ── Types ──────────────────────────────────────────────────────────
|
|
31
32
|
|
|
@@ -655,12 +656,9 @@ const checkAllowedTestCommandsSafety: DoctorCheck = {
|
|
|
655
656
|
const testCmds = context.config.allowedTestCommands || [];
|
|
656
657
|
results.push(checkResult("allowedTestCommands is non-empty", testCmds.length > 0, testCmds.length > 0 ? `${testCmds.length} commands` : "No test commands configured"));
|
|
657
658
|
|
|
658
|
-
const dangerous = ["rm -rf", "del /s", "format", "shutdown", "curl |", "wget |"];
|
|
659
659
|
for (const cmdStr of testCmds) {
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
results.push(warnResult(`allowedTestCommands contains dangerous pattern: "${cmdStr}"`));
|
|
663
|
-
}
|
|
660
|
+
if (isDangerousAllowedTestCommand(cmdStr)) {
|
|
661
|
+
results.push(warnResult(`allowedTestCommands contains dangerous pattern: "${cmdStr}"`));
|
|
664
662
|
}
|
|
665
663
|
}
|
|
666
664
|
return results;
|
package/src/runner/runTask.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
2
3
|
import {
|
|
3
4
|
existsSync,
|
|
4
5
|
readFileSync,
|
|
@@ -18,6 +19,7 @@ import {
|
|
|
18
19
|
type TaskStatusRecord,
|
|
19
20
|
} from "./taskStatusStore.js";
|
|
20
21
|
import { validateAssessmentFreshness } from "../assessments/assessmentStore.js";
|
|
22
|
+
import { recordAssessmentValidationFailure } from "../assessments/assessmentDiagnostics.js";
|
|
21
23
|
import { buildAgentInvocation, buildExecutionPrompt } from "./agentInvocation.js";
|
|
22
24
|
import type { TaskPhase, TaskStatus } from "../tools/tasks/createTask.js";
|
|
23
25
|
import {
|
|
@@ -117,6 +119,7 @@ interface TaskContext {
|
|
|
117
119
|
timeoutSeconds: number;
|
|
118
120
|
startedAtMs: number;
|
|
119
121
|
deadlineMs: number;
|
|
122
|
+
runnerInstanceId: string;
|
|
120
123
|
beforeSnapshot: RepoSnapshot;
|
|
121
124
|
beforeWorkspaceSnapshot: RepoSnapshot;
|
|
122
125
|
externalDirtyBaseline: ExternalDirtyFile[];
|
|
@@ -124,6 +127,7 @@ interface TaskContext {
|
|
|
124
127
|
|
|
125
128
|
interface ExecutionState {
|
|
126
129
|
agentResult: ManagedProcessResult | null;
|
|
130
|
+
agentFailureCategory: string | null;
|
|
127
131
|
testResult: TestExecutionResult;
|
|
128
132
|
verifyResults: TestExecutionResult[];
|
|
129
133
|
finalStatus: TaskStatus;
|
|
@@ -168,6 +172,7 @@ async function prepareTask(taskId: string): Promise<TaskContext | TaskRunResult>
|
|
|
168
172
|
if (!existsSync(statusFile)) throw new Error(`Task not found: "${taskId}"`);
|
|
169
173
|
|
|
170
174
|
const startedAtMs = Date.now();
|
|
175
|
+
const runnerInstanceId = randomUUID().replace(/-/g, "");
|
|
171
176
|
const claim = claimPendingTask(statusFile, {
|
|
172
177
|
status: "running",
|
|
173
178
|
phase: "preparing",
|
|
@@ -220,7 +225,11 @@ async function prepareTask(taskId: string): Promise<TaskContext | TaskRunResult>
|
|
|
220
225
|
const preExecSnapshot = await captureRepoSnapshot(repoPath);
|
|
221
226
|
const validation = validateAssessmentFreshness(assessmentId, preExecSnapshot);
|
|
222
227
|
if (!validation.valid) {
|
|
223
|
-
|
|
228
|
+
recordAssessmentValidationFailure(validation);
|
|
229
|
+
const changedFields = validation.config_change_categories?.length
|
|
230
|
+
? ` Changed fields: ${validation.config_change_categories.join(", ")}.`
|
|
231
|
+
: "";
|
|
232
|
+
const message = `assessment validation failed: ${validation.failure_reason}.${changedFields} Re-run create_task with execution_mode=assess_only to get a fresh assessment_id.`;
|
|
224
233
|
return failBeforeExecution(taskId, taskDir, message);
|
|
225
234
|
}
|
|
226
235
|
} catch (error) {
|
|
@@ -233,6 +242,8 @@ async function prepareTask(taskId: string): Promise<TaskContext | TaskRunResult>
|
|
|
233
242
|
writeTaskRuntime(taskDir, {
|
|
234
243
|
task_started_at: new Date(startedAtMs).toISOString(),
|
|
235
244
|
watcher_instance_id: process.env.PATCHWARDEN_WATCHER_INSTANCE_ID || undefined,
|
|
245
|
+
runner_pid: process.pid,
|
|
246
|
+
runner_instance_id: runnerInstanceId,
|
|
236
247
|
});
|
|
237
248
|
setTaskPhase(taskDir, "preparing", null, "Capturing pre-task repository state.");
|
|
238
249
|
|
|
@@ -253,13 +264,14 @@ async function prepareTask(taskId: string): Promise<TaskContext | TaskRunResult>
|
|
|
253
264
|
return {
|
|
254
265
|
taskId, taskDir, statusFile, repoPath, wsRoot, config, plansDir, initialStatus,
|
|
255
266
|
planId, agentName, testCommand, changePolicy, verifyCommands, timeoutSeconds,
|
|
256
|
-
startedAtMs, deadlineMs, beforeSnapshot, beforeWorkspaceSnapshot, externalDirtyBaseline,
|
|
267
|
+
startedAtMs, deadlineMs, runnerInstanceId, beforeSnapshot, beforeWorkspaceSnapshot, externalDirtyBaseline,
|
|
257
268
|
};
|
|
258
269
|
}
|
|
259
270
|
|
|
260
271
|
async function executeAgent(ctx: TaskContext): Promise<ExecutionState> {
|
|
261
272
|
const state: ExecutionState = {
|
|
262
273
|
agentResult: null,
|
|
274
|
+
agentFailureCategory: null,
|
|
263
275
|
testResult: skippedTest(ctx.testCommand, ctx.repoPath, "Agent did not complete successfully."),
|
|
264
276
|
verifyResults: [],
|
|
265
277
|
finalStatus: "failed",
|
|
@@ -284,6 +296,7 @@ async function executeAgent(ctx: TaskContext): Promise<ExecutionState> {
|
|
|
284
296
|
phase: "executing_agent",
|
|
285
297
|
currentCommand: invocation.commandLabel,
|
|
286
298
|
deadlineMs: ctx.deadlineMs,
|
|
299
|
+
runnerInstanceId: ctx.runnerInstanceId,
|
|
287
300
|
stdoutPath: join(ctx.taskDir, "stdout.log"),
|
|
288
301
|
stderrPath: join(ctx.taskDir, "stderr.log"),
|
|
289
302
|
environmentVariableNames: invocation.environmentVariableNames,
|
|
@@ -297,11 +310,15 @@ async function executeAgent(ctx: TaskContext): Promise<ExecutionState> {
|
|
|
297
310
|
? "Task was terminated by kill_task."
|
|
298
311
|
: "Task was canceled by user request.";
|
|
299
312
|
} else if (agentResult.terminationReason === "timeout") {
|
|
313
|
+
state.finalStatus = "timeout";
|
|
300
314
|
state.finalError = `Task timed out after ${ctx.timeoutSeconds} seconds during agent execution.`;
|
|
301
315
|
} else if (agentResult.spawnError) {
|
|
302
316
|
state.finalError = `Agent spawn failed: ${agentResult.spawnError}`;
|
|
303
317
|
} else if (agentResult.exitCode !== 0) {
|
|
304
|
-
state.
|
|
318
|
+
state.agentFailureCategory = classifyAgentFailure(agentResult.stderr);
|
|
319
|
+
state.finalError = state.agentFailureCategory
|
|
320
|
+
? `Agent provider failure (${state.agentFailureCategory}); process exited with code ${agentResult.exitCode}.`
|
|
321
|
+
: `Agent exited with code ${agentResult.exitCode}.`;
|
|
305
322
|
}
|
|
306
323
|
} catch (error) {
|
|
307
324
|
state.lastCaughtError = error;
|
|
@@ -318,7 +335,14 @@ async function runVerification(ctx: TaskContext, state: ExecutionState): Promise
|
|
|
318
335
|
if (ctx.verifyCommands.length > 0) {
|
|
319
336
|
for (const command of ctx.verifyCommands) {
|
|
320
337
|
setTaskPhase(ctx.taskDir, "running_tests", command);
|
|
321
|
-
const verification = await runTrustedTestCommand(
|
|
338
|
+
const verification = await runTrustedTestCommand(
|
|
339
|
+
command,
|
|
340
|
+
ctx.repoPath,
|
|
341
|
+
ctx.taskDir,
|
|
342
|
+
ctx.statusFile,
|
|
343
|
+
ctx.deadlineMs,
|
|
344
|
+
ctx.runnerInstanceId,
|
|
345
|
+
);
|
|
322
346
|
state.verifyResults.push(verification);
|
|
323
347
|
if (verification.terminationReason || Date.now() >= ctx.deadlineMs) break;
|
|
324
348
|
}
|
|
@@ -331,6 +355,7 @@ async function runVerification(ctx: TaskContext, state: ExecutionState): Promise
|
|
|
331
355
|
? "Task was terminated by kill_task during verification."
|
|
332
356
|
: "Task was canceled during verification.";
|
|
333
357
|
} else if (interrupted?.terminationReason === "timeout" || Date.now() >= ctx.deadlineMs) {
|
|
358
|
+
state.finalStatus = "timeout";
|
|
334
359
|
state.finalError = `Task timed out after ${ctx.timeoutSeconds} seconds during verification.`;
|
|
335
360
|
} else if (failedVerification) {
|
|
336
361
|
state.finalStatus = "failed_verification";
|
|
@@ -542,7 +567,7 @@ function finalizeTask(ctx: TaskContext, state: ExecutionState, evidence: Artifac
|
|
|
542
567
|
atomicWriteJsonFileSync(join(ctx.taskDir, "verify.json"), verifyJson);
|
|
543
568
|
atomicWriteFileSync(join(ctx.taskDir, "verify.log"), buildVerifyLog(verifyJson.commands));
|
|
544
569
|
|
|
545
|
-
if (!["canceled", "done_by_agent", "failed_verification", "failed_scope_violation", "failed_policy_violation"].includes(state.finalStatus)) state.finalStatus = "failed";
|
|
570
|
+
if (!["canceled", "timeout", "done_by_agent", "failed_verification", "failed_scope_violation", "failed_policy_violation"].includes(state.finalStatus)) state.finalStatus = "failed";
|
|
546
571
|
const finalPhase: TaskPhase = state.finalStatus === "done_by_agent" ? "done_by_agent" : (state.finalStatus as TaskPhase);
|
|
547
572
|
const followup = buildFailureFollowup(state.finalStatus, state.finalError, verifyJson.commands);
|
|
548
573
|
|
|
@@ -600,6 +625,10 @@ function finalizeTask(ctx: TaskContext, state: ExecutionState, evidence: Artifac
|
|
|
600
625
|
last_heartbeat_at: finishedAt,
|
|
601
626
|
finished_at: finishedAt,
|
|
602
627
|
error: state.finalError,
|
|
628
|
+
termination_reason: state.finalStatus === "timeout"
|
|
629
|
+
? "timeout"
|
|
630
|
+
: state.finalStatus === "canceled" ? "canceled" : null,
|
|
631
|
+
agent_failure_category: state.agentFailureCategory,
|
|
603
632
|
changed_files: changes.changed_files.map(({ path, change }) => ({ path, change })),
|
|
604
633
|
artifact_hygiene_counts: changes.artifact_hygiene.counts,
|
|
605
634
|
artifact_status: artifactStatus,
|
|
@@ -662,6 +691,10 @@ function buildResultJson(input: {
|
|
|
662
691
|
template: ctx.initialStatus.template || null,
|
|
663
692
|
change_policy: ctx.changePolicy,
|
|
664
693
|
summary: state.finalError || "Agent execution and configured verification completed successfully.",
|
|
694
|
+
termination_reason: state.finalStatus === "timeout"
|
|
695
|
+
? "timeout"
|
|
696
|
+
: state.finalStatus === "canceled" ? "canceled" : null,
|
|
697
|
+
agent_failure_category: state.agentFailureCategory,
|
|
665
698
|
changed_files: changes.changed_files,
|
|
666
699
|
changed_file_groups: {
|
|
667
700
|
source_changes: changedFileGroups.source_changes.length,
|
|
@@ -792,6 +825,7 @@ async function runManagedProcess(options: {
|
|
|
792
825
|
environmentVariableNames?: string[];
|
|
793
826
|
blockedEnvironmentVariableNames?: string[];
|
|
794
827
|
maxLogBytes?: number;
|
|
828
|
+
runnerInstanceId: string;
|
|
795
829
|
}): Promise<ManagedProcessResult> {
|
|
796
830
|
if (Date.now() >= options.deadlineMs) {
|
|
797
831
|
return { exitCode: null, stdout: "", stderr: "", spawnError: null, terminationReason: "timeout" };
|
|
@@ -825,6 +859,9 @@ async function runManagedProcess(options: {
|
|
|
825
859
|
writeTaskRuntime(options.taskDir, {
|
|
826
860
|
child_pid: child.pid,
|
|
827
861
|
child_started_at: new Date().toISOString(),
|
|
862
|
+
runner_pid: process.pid,
|
|
863
|
+
runner_instance_id: options.runnerInstanceId,
|
|
864
|
+
child_owned_by_runner_instance_id: options.runnerInstanceId,
|
|
828
865
|
});
|
|
829
866
|
|
|
830
867
|
let stdout = "";
|
|
@@ -939,7 +976,8 @@ async function runTrustedTestCommand(
|
|
|
939
976
|
repoPath: string,
|
|
940
977
|
taskDir: string,
|
|
941
978
|
statusFile: string,
|
|
942
|
-
deadlineMs: number
|
|
979
|
+
deadlineMs: number,
|
|
980
|
+
runnerInstanceId: string,
|
|
943
981
|
): Promise<TestExecutionResult> {
|
|
944
982
|
const config = getConfig();
|
|
945
983
|
const trusted = guardTestCommand(testCommand, config, repoPath);
|
|
@@ -962,6 +1000,7 @@ async function runTrustedTestCommand(
|
|
|
962
1000
|
phase: "running_tests",
|
|
963
1001
|
currentCommand: trusted,
|
|
964
1002
|
deadlineMs,
|
|
1003
|
+
runnerInstanceId,
|
|
965
1004
|
stdoutPath: join(taskDir, "test.stdout.log"),
|
|
966
1005
|
stderrPath: join(taskDir, "test.stderr.log"),
|
|
967
1006
|
});
|
|
@@ -1337,3 +1376,17 @@ function appendBounded(current: string, next: string): string {
|
|
|
1337
1376
|
function errorMessage(error: unknown): string {
|
|
1338
1377
|
return error instanceof Error ? error.message : String(error);
|
|
1339
1378
|
}
|
|
1379
|
+
|
|
1380
|
+
export function classifyAgentFailure(stderr: string): string | null {
|
|
1381
|
+
const normalized = stderr.toLowerCase();
|
|
1382
|
+
if (normalized.includes("insufficient balance") || normalized.includes("insufficient credits")) {
|
|
1383
|
+
return "provider_insufficient_balance";
|
|
1384
|
+
}
|
|
1385
|
+
if (normalized.includes("unauthorized") || normalized.includes("authentication failed") || normalized.includes("invalid api key")) {
|
|
1386
|
+
return "provider_authentication_failed";
|
|
1387
|
+
}
|
|
1388
|
+
if (normalized.includes("model not found") || normalized.includes("model access") || normalized.includes("permission denied")) {
|
|
1389
|
+
return "provider_model_unavailable";
|
|
1390
|
+
}
|
|
1391
|
+
return null;
|
|
1392
|
+
}
|
|
@@ -8,6 +8,8 @@ export interface TaskRuntimeData {
|
|
|
8
8
|
last_heartbeat_at: string;
|
|
9
9
|
current_command: string | null;
|
|
10
10
|
runner_pid?: number;
|
|
11
|
+
runner_instance_id?: string;
|
|
12
|
+
child_owned_by_runner_instance_id?: string;
|
|
11
13
|
child_pid?: number;
|
|
12
14
|
/**
|
|
13
15
|
* v0.7.0: ISO timestamp when the child process was spawned.
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
readJsonObjectFileSync,
|
|
5
5
|
type LockedJsonMutation,
|
|
6
6
|
} from "../utils/lockedJsonFile.js";
|
|
7
|
+
import { isAllowedTaskStatusTransition } from "../tools/tasks/taskStates.js";
|
|
7
8
|
|
|
8
9
|
export type TaskStatusRecord = Record<string, unknown>;
|
|
9
10
|
|
|
@@ -22,7 +23,21 @@ export function mutateTaskStatus<T>(
|
|
|
22
23
|
statusFile: string,
|
|
23
24
|
mutation: (current: TaskStatusRecord) => TaskStatusMutation<T>,
|
|
24
25
|
): T {
|
|
25
|
-
return mutateLockedJsonFileSync(statusFile,
|
|
26
|
+
return mutateLockedJsonFileSync<TaskStatusRecord, T>(statusFile, (current) => {
|
|
27
|
+
const outcome = mutation(current);
|
|
28
|
+
if (outcome.next) {
|
|
29
|
+
const from = String(current.status || "");
|
|
30
|
+
const to = String(outcome.next.status || from);
|
|
31
|
+
if (!isAllowedTaskStatusTransition(from, to)) {
|
|
32
|
+
throw new PatchWardenError(
|
|
33
|
+
"invalid_task_status_transition",
|
|
34
|
+
`Task status cannot transition from "${from || "unknown"}" to "${to || "unknown"}".`,
|
|
35
|
+
"Refresh the task status and apply only a forward lifecycle transition.",
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return outcome;
|
|
40
|
+
}, {
|
|
26
41
|
busyError: () => new PatchWardenError(
|
|
27
42
|
"task_status_busy",
|
|
28
43
|
"Task status is currently being updated by another PatchWarden process.",
|