principles-disciple 1.125.0 → 1.126.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/core/rule-host.ts +33 -4
- package/src/core/rule-implementation-runtime.ts +61 -16
- package/src/utils/io.ts +23 -5
- package/tests/core/rule-host-unhealthy-visibility.test.ts +27 -0
- package/tests/core/rule-implementation-runtime.test.ts +12 -0
package/openclaw.plugin.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"id": "principles-disciple",
|
|
3
3
|
"name": "Principles Disciple",
|
|
4
4
|
"description": "Evolutionary programming agent framework with strategic guardrails and reflection loops.",
|
|
5
|
-
"version": "1.
|
|
5
|
+
"version": "1.126.0",
|
|
6
6
|
"activation": {
|
|
7
7
|
"onCapabilities": [
|
|
8
8
|
"hook"
|
package/package.json
CHANGED
package/src/core/rule-host.ts
CHANGED
|
@@ -66,6 +66,7 @@ export class RuleHost {
|
|
|
66
66
|
private readonly stateDir: string;
|
|
67
67
|
private readonly logger: RuleHostLogger;
|
|
68
68
|
private readonly workspaceDir: string | null;
|
|
69
|
+
private readonly implementationSources = new Map<string, { activationId: string; artifactId: string; ruleId: string }>();
|
|
69
70
|
|
|
70
71
|
constructor(stateDir: string, logger: RuleHostLogger = console, options?: RuleHostOptions) {
|
|
71
72
|
this.stateDir = stateDir;
|
|
@@ -85,7 +86,23 @@ export class RuleHost {
|
|
|
85
86
|
evaluate(input: RuleHostInput): RuleHostResult | undefined {
|
|
86
87
|
try {
|
|
87
88
|
const activeImpls = this._loadActiveCodeImplementations();
|
|
88
|
-
return mergeDecisions(activeImpls, input,
|
|
89
|
+
return mergeDecisions(activeImpls, input, {
|
|
90
|
+
warn: this.logger.warn,
|
|
91
|
+
onImplementationUnhealthy: (impl, reason) => {
|
|
92
|
+
const source = this.implementationSources.get(impl.implId);
|
|
93
|
+
if (!source) {
|
|
94
|
+
this.logger.warn?.(`[RuleHost] No source mapping for implId=${impl.implId}, cannot record unhealthy event`);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
this._recordUnhealthy(
|
|
98
|
+
source.activationId,
|
|
99
|
+
source.artifactId,
|
|
100
|
+
source.ruleId,
|
|
101
|
+
reason,
|
|
102
|
+
'Fix the RuleCode runtime error or return shape, then re-activate the rule',
|
|
103
|
+
);
|
|
104
|
+
},
|
|
105
|
+
});
|
|
89
106
|
} catch (hostError: unknown) {
|
|
90
107
|
// Conservative degradation: log and return undefined (D-08)
|
|
91
108
|
this.logger.warn?.(
|
|
@@ -136,6 +153,7 @@ export class RuleHost {
|
|
|
136
153
|
* All data from SQLite is treated as unknown and validated before use.
|
|
137
154
|
*/
|
|
138
155
|
private _loadFromActivationsTable(workspaceDir: string): LoadedImplementation[] {
|
|
156
|
+
this.implementationSources.clear();
|
|
139
157
|
const sqliteConn = new SqliteConnection(workspaceDir);
|
|
140
158
|
try {
|
|
141
159
|
const db = sqliteConn.getDb();
|
|
@@ -199,6 +217,19 @@ export class RuleHost {
|
|
|
199
217
|
`reason=at most one active activation per rule is allowed ` +
|
|
200
218
|
`nextAction=deactivate all but one activation for this target_ref`
|
|
201
219
|
);
|
|
220
|
+
for (const r of group) {
|
|
221
|
+
const activationId = typeof r['activation_id'] === 'string' ? r['activation_id'] : '';
|
|
222
|
+
const artifactId = typeof r['artifact_id'] === 'string' ? r['artifact_id'] : '';
|
|
223
|
+
if (activationId && artifactId) {
|
|
224
|
+
this._recordUnhealthy(
|
|
225
|
+
activationId,
|
|
226
|
+
artifactId,
|
|
227
|
+
targetRef,
|
|
228
|
+
`duplicate active activation for target_ref ${targetRef}`,
|
|
229
|
+
'Deactivate all but one activation for this target_ref',
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
202
233
|
} else {
|
|
203
234
|
validRows.push(group[0]);
|
|
204
235
|
}
|
|
@@ -267,6 +298,7 @@ export class RuleHost {
|
|
|
267
298
|
// callEvaluate runs the invocation INSIDE the vm context with a time
|
|
268
299
|
// boundary, terminating infinite loops and excessive computation.
|
|
269
300
|
const boundedCallEvaluate = moduleExports.callEvaluate;
|
|
301
|
+
this.implementationSources.set(implId, { activationId, artifactId, ruleId });
|
|
270
302
|
|
|
271
303
|
loaded.push({
|
|
272
304
|
implId,
|
|
@@ -275,9 +307,6 @@ export class RuleHost {
|
|
|
275
307
|
evaluate: (input: RuleHostInput): RuleHostResult => {
|
|
276
308
|
const frozenHelpers = createRuleHostHelpers(input);
|
|
277
309
|
// PRI-437: Execute inside vm context with timeout boundary.
|
|
278
|
-
// If the RuleCode infinite-loops or exceeds the time budget,
|
|
279
|
-
// vm throws an error that is caught by the caller (mergeDecisions
|
|
280
|
-
// try/catch), resulting in conservative degradation (undefined).
|
|
281
310
|
const rawResult = boundedCallEvaluate(input, frozenHelpers);
|
|
282
311
|
const validation = validateRuleHostResult(rawResult);
|
|
283
312
|
if (!validation.valid) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { nodeVm } from '../utils/node-vm-polyfill.js';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
2
3
|
|
|
3
4
|
export interface RuleImplementationModuleExports {
|
|
4
5
|
meta?: unknown;
|
|
@@ -22,7 +23,35 @@ export interface RuleImplementationModuleExports {
|
|
|
22
23
|
const COMPILE_TIMEOUT_MS = 1000;
|
|
23
24
|
|
|
24
25
|
/** Timeout (ms) for executing evaluate(input, helpers) inside the vm. */
|
|
25
|
-
const
|
|
26
|
+
const EVALUATE_PROCESS_TIMEOUT_MS = 3000;
|
|
27
|
+
const EVALUATE_PROCESS_OUTPUT_BYTES = 1024 * 1024;
|
|
28
|
+
|
|
29
|
+
const EVALUATION_PROCESS_SOURCE = String.raw`
|
|
30
|
+
const vm = require('node:vm');
|
|
31
|
+
try {
|
|
32
|
+
const workerData = JSON.parse(require('node:fs').readFileSync(0, 'utf8'));
|
|
33
|
+
const context = vm.createContext(Object.create(null));
|
|
34
|
+
new vm.Script(workerData.source, { filename: workerData.filename }).runInContext(context, { timeout: 1000 });
|
|
35
|
+
const input = workerData.input;
|
|
36
|
+
const helpers = Object.freeze({
|
|
37
|
+
isRiskPath: () => input.workspace.isRiskPath,
|
|
38
|
+
getToolName: () => input.action.toolName,
|
|
39
|
+
getEstimatedLineChanges: () => input.derived.estimatedLineChanges,
|
|
40
|
+
getBashRisk: () => input.derived.bashRisk,
|
|
41
|
+
hasPlanFile: () => input.workspace.hasPlanFile,
|
|
42
|
+
getPlanStatus: () => input.workspace.planStatus,
|
|
43
|
+
getEpTier: () => input.evolution.epTier,
|
|
44
|
+
});
|
|
45
|
+
context.__pdCallInput = input;
|
|
46
|
+
context.__pdCallHelpers = helpers;
|
|
47
|
+
const result = new vm.Script('__pdRuleModule.evaluate(__pdCallInput, __pdCallHelpers)', { filename: workerData.filename + '.call' })
|
|
48
|
+
.runInContext(context, { timeout: 1000 });
|
|
49
|
+
process.stdout.write(JSON.stringify({ ok: true, result }));
|
|
50
|
+
} catch (error) {
|
|
51
|
+
process.stdout.write(JSON.stringify({ ok: false, error: String(error) }));
|
|
52
|
+
process.exitCode = 1;
|
|
53
|
+
}
|
|
54
|
+
`;
|
|
26
55
|
|
|
27
56
|
function normalizeImplementationSource(sourceCode: string): string {
|
|
28
57
|
const withoutExports = sourceCode
|
|
@@ -36,6 +65,10 @@ globalThis.__pdRuleModule = {
|
|
|
36
65
|
};`;
|
|
37
66
|
}
|
|
38
67
|
|
|
68
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
69
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
70
|
+
}
|
|
71
|
+
|
|
39
72
|
export function loadRuleImplementationModule(
|
|
40
73
|
sourceCode: string,
|
|
41
74
|
filename: string,
|
|
@@ -73,23 +106,35 @@ export function loadRuleImplementationModule(
|
|
|
73
106
|
// 3. Runs it in the context with EVALUATE_TIMEOUT_MS
|
|
74
107
|
// 4. Cleans up globals
|
|
75
108
|
// 5. Returns the raw result (validation happens in the caller)
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
109
|
+
const normalizedSource = normalizeImplementationSource(sourceCode);
|
|
110
|
+
const callEvaluate = (input: unknown, _helpers: unknown): unknown => {
|
|
111
|
+
const child = spawnSync(process.execPath, [
|
|
112
|
+
'--max-old-space-size=32',
|
|
113
|
+
'-e',
|
|
114
|
+
EVALUATION_PROCESS_SOURCE,
|
|
115
|
+
], {
|
|
116
|
+
input: JSON.stringify({ source: normalizedSource, filename, input }),
|
|
117
|
+
encoding: 'utf8',
|
|
118
|
+
timeout: EVALUATE_PROCESS_TIMEOUT_MS,
|
|
119
|
+
maxBuffer: EVALUATE_PROCESS_OUTPUT_BYTES,
|
|
120
|
+
windowsHide: true,
|
|
121
|
+
});
|
|
122
|
+
if (child.error) {
|
|
123
|
+
throw new Error(`RuleCode process failed: ${child.error.message}`);
|
|
124
|
+
}
|
|
125
|
+
let parsed: unknown;
|
|
84
126
|
try {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
});
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
|
|
127
|
+
parsed = JSON.parse(child.stdout);
|
|
128
|
+
} catch {
|
|
129
|
+
const stderr = child.stderr.trim().slice(0, 500);
|
|
130
|
+
throw new Error(`RuleCode process exited without a valid result${stderr ? `: ${stderr}` : ''}`);
|
|
131
|
+
}
|
|
132
|
+
if (!isRecord(parsed) || !Object.hasOwn(parsed, 'ok') || parsed['ok'] !== true || !Object.hasOwn(parsed, 'result')) {
|
|
133
|
+
const reason = isRecord(parsed) && Object.hasOwn(parsed, 'error') && typeof parsed['error'] === 'string'
|
|
134
|
+
? parsed['error'] : 'RuleCode process failed';
|
|
135
|
+
throw new Error(reason);
|
|
92
136
|
}
|
|
137
|
+
return parsed['result'];
|
|
93
138
|
};
|
|
94
139
|
|
|
95
140
|
return {
|
package/src/utils/io.ts
CHANGED
|
@@ -66,16 +66,34 @@ export function normalizePath(filePath: string, projectDir: string): string {
|
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
|
|
70
|
-
|
|
69
|
+
// POSIX absolute paths (e.g., /etc/passwd) are NOT recognized as absolute
|
|
70
|
+
// by path.isAbsolute() on Windows. When both the project dir and the file
|
|
71
|
+
// path are POSIX paths, use path.posix for relative computation.
|
|
72
|
+
const isPosixAbsolute = normalizedFilePath.startsWith('/') && !fileIsWin;
|
|
73
|
+
const projectIsPosix = !projectIsWin;
|
|
74
|
+
|
|
71
75
|
let rel: string;
|
|
72
76
|
if (projectIsWin) {
|
|
73
77
|
const projectAbs = path.resolve(projectDir);
|
|
74
|
-
|
|
75
|
-
|
|
78
|
+
if (isPosixAbsolute) {
|
|
79
|
+
// POSIX absolute path on Windows project: cannot resolve relative to project,
|
|
80
|
+
// return as-is (path is outside the project directory).
|
|
81
|
+
rel = normalizedFilePath;
|
|
82
|
+
} else {
|
|
83
|
+
const fileAbs = path.isAbsolute(normalizedFilePath)
|
|
84
|
+
? normalizedFilePath
|
|
85
|
+
: path.join(projectAbs, normalizedFilePath);
|
|
86
|
+
rel = path.relative(projectAbs, fileAbs);
|
|
87
|
+
}
|
|
88
|
+
} else if (projectIsPosix && isPosixAbsolute) {
|
|
89
|
+
// Both project and file are POSIX → use path.posix
|
|
90
|
+
const projectPosix = projectDir.replace(/\\/g, '/');
|
|
91
|
+
rel = path.posix.relative(projectPosix, normalizedFilePath);
|
|
76
92
|
} else {
|
|
77
93
|
const projectPosix = projectDir.replace(/\\/g, '/');
|
|
78
|
-
const filePosix = path.isAbsolute(normalizedFilePath)
|
|
94
|
+
const filePosix = path.isAbsolute(normalizedFilePath)
|
|
95
|
+
? normalizedFilePath
|
|
96
|
+
: path.posix.join(projectPosix, normalizedFilePath.replace(/\\/g, '/'));
|
|
79
97
|
rel = path.posix.relative(projectPosix, filePosix);
|
|
80
98
|
}
|
|
81
99
|
|
|
@@ -231,4 +231,31 @@ var meta = { name: 'valid-rule', version: '1', ruleId: '${RULE_ID_BROKEN}', cove
|
|
|
231
231
|
const eventsContent = readTodayEvents(tempStateDir);
|
|
232
232
|
expect(eventsContent).not.toContain('rulehost_unhealthy');
|
|
233
233
|
});
|
|
234
|
+
|
|
235
|
+
it('runtime-invalid result is persisted as rulehost_unhealthy and never executes', async () => {
|
|
236
|
+
const invalidResultCode = `
|
|
237
|
+
function evaluate() {
|
|
238
|
+
return { decision: 'block', matched: 'yes', reason: 'invalid matched type' };
|
|
239
|
+
}
|
|
240
|
+
var meta = { name: 'invalid-result-rule', version: '1', ruleId: '${RULE_ID_BROKEN}', coversCondition: 'all' };
|
|
241
|
+
`;
|
|
242
|
+
const now = new Date().toISOString();
|
|
243
|
+
sqliteConn.getDb().prepare(`
|
|
244
|
+
INSERT INTO pi_artifacts (artifact_id, artifact_kind, source_task_id, source_principle_id, source_rule_id, lineage_artifact_ids, validation_status, content_json, created_at, updated_at)
|
|
245
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
246
|
+
`).run(
|
|
247
|
+
ARTIFACT_ID_BROKEN, 'rule', 'task-invalid-result', 'P_TEST_INVALID_RESULT', RULE_ID_BROKEN,
|
|
248
|
+
'[]', 'validated', JSON.stringify({ ruleId: RULE_ID_BROKEN, implementationCode: invalidResultCode }), now, now,
|
|
249
|
+
);
|
|
250
|
+
await insertActivation();
|
|
251
|
+
|
|
252
|
+
const ruleHost = new RuleHost(tempStateDir, { warn: () => {} }, { workspaceDir: tempWorkspaceDir });
|
|
253
|
+
expect(ruleHost.evaluate(makeInput())).toBeUndefined();
|
|
254
|
+
EventLogService.get(tempStateDir).flush();
|
|
255
|
+
|
|
256
|
+
const events = readTodayEvents(tempStateDir);
|
|
257
|
+
expect(events).toContain('rulehost_unhealthy');
|
|
258
|
+
expect(events).toContain('invalid RuleHostResult');
|
|
259
|
+
expect(events).toContain(ACTIVATION_ID_BROKEN);
|
|
260
|
+
});
|
|
234
261
|
});
|
|
@@ -34,4 +34,16 @@ describe('rule-implementation-runtime', () => {
|
|
|
34
34
|
|
|
35
35
|
expect((globalThis as Record<string, unknown>).__pdRuleHostLeak).toBeUndefined();
|
|
36
36
|
});
|
|
37
|
+
|
|
38
|
+
it('contains memory-exhausting evaluation in a resource-limited worker', () => {
|
|
39
|
+
const moduleExports = loadRuleImplementationModule(
|
|
40
|
+
`export function evaluate() {
|
|
41
|
+
const memoryBomb = new Array(100_000_000).fill('x');
|
|
42
|
+
return { decision: 'allow', matched: false, reason: String(memoryBomb.length) };
|
|
43
|
+
}`,
|
|
44
|
+
'rule-memory-limit.js',
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
expect(() => moduleExports.callEvaluate?.({}, {})).toThrow(/worker|memory|heap|timed out|exited without a valid result/i);
|
|
48
|
+
});
|
|
37
49
|
});
|