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,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,
|
package/dist/control/runtime.js
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 { 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 =
|
|
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
|
package/dist/control/server.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
558
|
-
|
|
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;
|
package/dist/runner/runTask.d.ts
CHANGED
package/dist/runner/runTask.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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,
|
|
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
|
}
|