principles-disciple 1.100.0 → 1.102.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/config-health.ts +72 -0
- package/src/hooks/trajectory-collector.ts +75 -10
- package/src/index.ts +33 -52
- package/src/openclaw-sdk.ts +1 -0
- package/tests/core-anti-growth.test.ts +1 -0
- package/tests/evolution-worker-slimming.test.ts +1 -0
- package/tests/hooks/trajectory-collector.test.ts +269 -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.102.0",
|
|
6
6
|
"activation": {
|
|
7
7
|
"onCapabilities": [
|
|
8
8
|
"hook"
|
package/package.json
CHANGED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conversation Access Health Check (PRI-343 / PRI-346)
|
|
3
|
+
*
|
|
4
|
+
* Pure function for checking whether OpenClaw plugin config has
|
|
5
|
+
* allowConversationAccess set to true. When missing, llm_output and
|
|
6
|
+
* trajectory hooks are silently blocked by OpenClaw, causing evidence
|
|
7
|
+
* to always be empty (PRI-338 root cause).
|
|
8
|
+
*
|
|
9
|
+
* Extracted from index.ts to avoid circular imports when trajectory-collector.ts
|
|
10
|
+
* needs to check conversation access state (PRI-346).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* PRI-348: Extract the full plugin entry (including hooks) from the global OpenClaw config.
|
|
15
|
+
* Unlike api.pluginConfig (which is only the entry.config sub-object), this returns
|
|
16
|
+
* the entire entry including hooks, config, enabled, etc.
|
|
17
|
+
*/
|
|
18
|
+
export function getPluginEntry(config: unknown, pluginId: string): unknown {
|
|
19
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) return undefined;
|
|
20
|
+
const plugins = (config as Record<string, unknown>).plugins;
|
|
21
|
+
if (!plugins || typeof plugins !== 'object' || Array.isArray(plugins)) return undefined;
|
|
22
|
+
const entries = (plugins as Record<string, unknown>).entries;
|
|
23
|
+
if (!entries || typeof entries !== 'object' || Array.isArray(entries)) return undefined;
|
|
24
|
+
return (entries as Record<string, unknown>)[pluginId];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Keep in sync with @principles/core CONVERSATION_ACCESS_CONFIG_KEY */
|
|
28
|
+
const CONVERSATION_ACCESS_CONFIG_KEY = 'allowConversationAccess' as const;
|
|
29
|
+
|
|
30
|
+
export interface ConversationAccessCheckResult {
|
|
31
|
+
authorized: boolean;
|
|
32
|
+
reason?: string;
|
|
33
|
+
nextAction?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const CONVERSATION_ACCESS_FIX_COMMAND =
|
|
37
|
+
'openclaw config set plugins.entries.principles-disciple.hooks.allowConversationAccess true --strict-json';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* PRI-343: Pure function — checks if pluginConfig has hooks.allowConversationAccess === true.
|
|
41
|
+
* Returns a structured result with reason and nextAction when not authorized (ERR-002).
|
|
42
|
+
*/
|
|
43
|
+
export function checkConversationAccessConfig(pluginConfig: unknown): ConversationAccessCheckResult {
|
|
44
|
+
if (pluginConfig === null || pluginConfig === undefined || typeof pluginConfig !== 'object' || Array.isArray(pluginConfig)) {
|
|
45
|
+
return {
|
|
46
|
+
authorized: false,
|
|
47
|
+
reason: 'pluginConfig is missing or invalid — conversation hooks cannot be registered',
|
|
48
|
+
nextAction: CONVERSATION_ACCESS_FIX_COMMAND,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const config = pluginConfig as Record<string, unknown>;
|
|
53
|
+
|
|
54
|
+
if (typeof config.hooks !== 'object' || config.hooks === null || Array.isArray(config.hooks)) {
|
|
55
|
+
return {
|
|
56
|
+
authorized: false,
|
|
57
|
+
reason: 'allowConversationAccess is not set to true',
|
|
58
|
+
nextAction: CONVERSATION_ACCESS_FIX_COMMAND,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const hooks = config.hooks as Record<string, unknown>;
|
|
63
|
+
if (hooks[CONVERSATION_ACCESS_CONFIG_KEY] !== true) {
|
|
64
|
+
return {
|
|
65
|
+
authorized: false,
|
|
66
|
+
reason: 'allowConversationAccess is not set to true',
|
|
67
|
+
nextAction: CONVERSATION_ACCESS_FIX_COMMAND,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return { authorized: true };
|
|
72
|
+
}
|
|
@@ -13,6 +13,10 @@ import type {
|
|
|
13
13
|
PluginHookBeforeMessageWriteEvent
|
|
14
14
|
} from '../openclaw-sdk.js';
|
|
15
15
|
import { MAX_STRING_LENGTH } from '../config/defaults/runtime.js';
|
|
16
|
+
import { WorkspaceContext } from '../core/workspace-context.js';
|
|
17
|
+
import { SystemLogger } from '../core/system-logger.js';
|
|
18
|
+
import { sanitizeForEvidence } from './message-sanitize.js';
|
|
19
|
+
import { checkConversationAccessConfig } from '../core/config-health.js';
|
|
16
20
|
|
|
17
21
|
const TRAJECTORY_DIR = 'memory/trajectories/';
|
|
18
22
|
|
|
@@ -144,25 +148,32 @@ function writeTrajectoryRecord(workspaceDir: string, record: object): void {
|
|
|
144
148
|
}
|
|
145
149
|
|
|
146
150
|
/**
|
|
147
|
-
*
|
|
148
|
-
* 记录:用户/助手消息内容
|
|
151
|
+
* PRI-346: Message write hook with SQLite fallback trajectory recording.
|
|
149
152
|
*
|
|
150
|
-
*
|
|
153
|
+
* When allowConversationAccess is NOT set (unauthorized), llm_output is silently
|
|
154
|
+
* blocked by OpenClaw and trajectory.db has no data. This hook is NOT in
|
|
155
|
+
* CONVERSATION_HOOK_NAMES, so it always fires — making it the natural fallback.
|
|
156
|
+
*
|
|
157
|
+
* De-duplication: only writes to SQLite when llm_output is blocked (unauthorized).
|
|
158
|
+
* When llm_output is working (authorized), this hook degrades to JSONL-only.
|
|
159
|
+
*
|
|
160
|
+
* ERR-002: structured observability when fallback fires.
|
|
161
|
+
* ERR-001/005: content is sanitized before persisting.
|
|
151
162
|
*/
|
|
152
163
|
export function handleBeforeMessageWrite(
|
|
153
164
|
event: PluginHookBeforeMessageWriteEvent,
|
|
154
|
-
ctx: PluginHookAgentContext & { workspaceDir?: string }
|
|
165
|
+
ctx: PluginHookAgentContext & { workspaceDir?: string; pluginConfig?: unknown }
|
|
155
166
|
): void {
|
|
156
|
-
const {workspaceDir} = ctx;
|
|
167
|
+
const { workspaceDir } = ctx;
|
|
157
168
|
if (!workspaceDir) return;
|
|
158
169
|
|
|
159
170
|
const msg = event.message;
|
|
160
171
|
if (!msg || !msg.role) return;
|
|
161
172
|
|
|
162
|
-
//
|
|
173
|
+
// Only record user and assistant messages
|
|
163
174
|
if (msg.role !== 'user' && msg.role !== 'assistant') return;
|
|
164
175
|
|
|
165
|
-
//
|
|
176
|
+
// Extract text content (consistent with existing implementation)
|
|
166
177
|
let content = '';
|
|
167
178
|
if (typeof msg.content === 'string') {
|
|
168
179
|
content = msg.content;
|
|
@@ -173,16 +184,70 @@ export function handleBeforeMessageWrite(
|
|
|
173
184
|
.join('\n');
|
|
174
185
|
}
|
|
175
186
|
|
|
176
|
-
//
|
|
187
|
+
// Sanitize content preview for JSONL
|
|
177
188
|
const sanitizedPreview = scrubSensitive(content.slice(0, 200));
|
|
178
189
|
|
|
190
|
+
// Existing JSONL write (always, for backward compatibility)
|
|
179
191
|
writeTrajectoryRecord(workspaceDir, {
|
|
180
192
|
type: 'message',
|
|
181
193
|
timestamp: new Date().toISOString(),
|
|
182
|
-
sessionId: event.sessionKey || 'unknown',
|
|
194
|
+
sessionId: event.sessionKey || event.sessionId || 'unknown',
|
|
183
195
|
role: msg.role,
|
|
184
196
|
contentLength: content.length,
|
|
185
197
|
contentPreview: typeof sanitizedPreview === 'string' ? sanitizedPreview : '[sanitized]',
|
|
186
|
-
agentId: event.agentId || null
|
|
198
|
+
agentId: event.agentId || null,
|
|
199
|
+
fallback: 'before_message_write',
|
|
187
200
|
});
|
|
201
|
+
|
|
202
|
+
// ── SQLite fallback (PRI-346): only when conversation hooks are blocked ──
|
|
203
|
+
const accessCheck = checkConversationAccessConfig(ctx.pluginConfig);
|
|
204
|
+
if (accessCheck.authorized) {
|
|
205
|
+
// llm_output is working — do NOT duplicate write to SQLite (de-dup, case D)
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Conversation hooks blocked — this hook is the fallback trajectory writer
|
|
210
|
+
if (msg.role === 'assistant') {
|
|
211
|
+
try {
|
|
212
|
+
const wctx = WorkspaceContext.fromHookContext({ workspaceDir, logger: ctx.logger });
|
|
213
|
+
const sanitized = sanitizeForEvidence(content.slice(0, MAX_STRING_LENGTH), workspaceDir);
|
|
214
|
+
const sessionId = (event.sessionKey as string | undefined) ?? ctx.sessionId ?? 'unknown';
|
|
215
|
+
wctx.trajectory?.recordAssistantTurn?.({
|
|
216
|
+
sessionId,
|
|
217
|
+
runId: 'before_message_write_fallback',
|
|
218
|
+
provider: 'unknown',
|
|
219
|
+
model: 'unknown',
|
|
220
|
+
rawText: content,
|
|
221
|
+
sanitizedText: sanitized,
|
|
222
|
+
usageJson: {},
|
|
223
|
+
empathySignalJson: { detected: false, severity: 'mild', confidence: 1 },
|
|
224
|
+
createdAt: new Date().toISOString(),
|
|
225
|
+
});
|
|
226
|
+
} catch (err) {
|
|
227
|
+
ctx.logger?.warn?.(`[PD:before_message_write] SQLite fallback write failed: ${String(err)}`);
|
|
228
|
+
}
|
|
229
|
+
} else if (msg.role === 'user') {
|
|
230
|
+
try {
|
|
231
|
+
const wctx = WorkspaceContext.fromHookContext({ workspaceDir, logger: ctx.logger });
|
|
232
|
+
const sessionId = (event.sessionKey as string | undefined) ?? ctx.sessionId ?? 'unknown';
|
|
233
|
+
wctx.trajectory?.recordUserTurn?.({
|
|
234
|
+
sessionId,
|
|
235
|
+
turnIndex: 0,
|
|
236
|
+
rawText: content.slice(0, MAX_STRING_LENGTH),
|
|
237
|
+
correctionDetected: false,
|
|
238
|
+
createdAt: new Date().toISOString(),
|
|
239
|
+
});
|
|
240
|
+
} catch (err) {
|
|
241
|
+
ctx.logger?.warn?.(`[PD:before_message_write] SQLite user turn fallback failed: ${String(err)}`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ERR-002: Structured observability — no silent fallback
|
|
246
|
+
SystemLogger.log(workspaceDir, 'CONVERSATION_HOOK_BLOCKED', JSON.stringify({
|
|
247
|
+
reason: accessCheck.reason,
|
|
248
|
+
nextAction: accessCheck.nextAction,
|
|
249
|
+
hook: 'llm_output',
|
|
250
|
+
fallback: 'before_message_write',
|
|
251
|
+
role: msg.role,
|
|
252
|
+
}));
|
|
188
253
|
}
|
package/src/index.ts
CHANGED
|
@@ -16,9 +16,13 @@ import type {
|
|
|
16
16
|
PluginHookSubagentSpawningEvent,
|
|
17
17
|
PluginHookSubagentSpawningResult,
|
|
18
18
|
PluginHookSubagentContext,
|
|
19
|
+
PluginHookBeforeMessageWriteEvent,
|
|
19
20
|
} from './openclaw-sdk.js';
|
|
20
21
|
import * as path from 'path';
|
|
21
22
|
import { loadFeatureFlagFromConfig } from './core/pd-config-loader.js';
|
|
23
|
+
import { checkConversationAccessConfig, getPluginEntry } from './core/config-health.js';
|
|
24
|
+
export { checkConversationAccessConfig, getPluginEntry } from './core/config-health.js';
|
|
25
|
+
export type { ConversationAccessCheckResult } from './core/config-health.js';
|
|
22
26
|
import { classifyTask } from './core/local-worker-routing.js';
|
|
23
27
|
import { completeShadowObservation, recordShadowRouting } from './core/shadow-observation-registry.js';
|
|
24
28
|
import { getCommandDescription } from './i18n/commands.js';
|
|
@@ -28,6 +32,7 @@ import { handleBeforeToolCall } from './hooks/gate.js';
|
|
|
28
32
|
import { handleAfterToolCall } from './hooks/pain.js';
|
|
29
33
|
import { handleBeforeReset, handleBeforeCompaction, handleAfterCompaction } from './hooks/lifecycle.js';
|
|
30
34
|
import { handleLlmOutput } from './hooks/llm.js';
|
|
35
|
+
import * as TrajectoryCollector from './hooks/trajectory-collector.js';
|
|
31
36
|
import { handleSubagentEnded } from './hooks/subagent.js';
|
|
32
37
|
import { handleInitStrategy } from './commands/strategy.js';
|
|
33
38
|
import { handleBootstrapTools, handleResearchTools } from './commands/capabilities.js';
|
|
@@ -70,57 +75,8 @@ const startedWorkspaces = new Set<string>();
|
|
|
70
75
|
const pendingShadowObservations = new Map<string, string>();
|
|
71
76
|
|
|
72
77
|
// ── Conversation Access Health Check (PRI-343) ────────────────────────────
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
// trajectory hooks are silently blocked by OpenClaw, causing evidence
|
|
76
|
-
// to always be empty (PRI-338 root cause).
|
|
77
|
-
|
|
78
|
-
/** Keep in sync with @principles/core CONVERSATION_ACCESS_CONFIG_KEY */
|
|
79
|
-
const CONVERSATION_ACCESS_CONFIG_KEY = 'allowConversationAccess' as const;
|
|
80
|
-
|
|
81
|
-
export interface ConversationAccessCheckResult {
|
|
82
|
-
authorized: boolean;
|
|
83
|
-
reason?: string;
|
|
84
|
-
nextAction?: string;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
const CONVERSATION_ACCESS_FIX_COMMAND =
|
|
88
|
-
'openclaw config set plugins.entries.principles-disciple.hooks.allowConversationAccess true --strict-json';
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* PRI-343: Pure function — checks if pluginConfig has hooks.allowConversationAccess === true.
|
|
92
|
-
* Returns a structured result with reason and nextAction when not authorized (ERR-002).
|
|
93
|
-
*/
|
|
94
|
-
export function checkConversationAccessConfig(pluginConfig: unknown): ConversationAccessCheckResult {
|
|
95
|
-
if (pluginConfig === null || pluginConfig === undefined || typeof pluginConfig !== 'object' || Array.isArray(pluginConfig)) {
|
|
96
|
-
return {
|
|
97
|
-
authorized: false,
|
|
98
|
-
reason: 'pluginConfig is missing or invalid — conversation hooks cannot be registered',
|
|
99
|
-
nextAction: CONVERSATION_ACCESS_FIX_COMMAND,
|
|
100
|
-
};
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
const config = pluginConfig as Record<string, unknown>;
|
|
104
|
-
|
|
105
|
-
if (typeof config.hooks !== 'object' || config.hooks === null || Array.isArray(config.hooks)) {
|
|
106
|
-
return {
|
|
107
|
-
authorized: false,
|
|
108
|
-
reason: 'allowConversationAccess is not set to true',
|
|
109
|
-
nextAction: CONVERSATION_ACCESS_FIX_COMMAND,
|
|
110
|
-
};
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
const hooks = config.hooks as Record<string, unknown>;
|
|
114
|
-
if (hooks[CONVERSATION_ACCESS_CONFIG_KEY] !== true) {
|
|
115
|
-
return {
|
|
116
|
-
authorized: false,
|
|
117
|
-
reason: 'allowConversationAccess is not set to true',
|
|
118
|
-
nextAction: CONVERSATION_ACCESS_FIX_COMMAND,
|
|
119
|
-
};
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
return { authorized: true };
|
|
123
|
-
}
|
|
78
|
+
// Re-exported from core/config-health.ts for backward compatibility.
|
|
79
|
+
// Implementation moved to avoid circular imports with trajectory-collector.ts.
|
|
124
80
|
|
|
125
81
|
// ── Feature Flag Loader (plugin I/O boundary) ─────────────────────────────
|
|
126
82
|
// Reads workspace feature-flags.yaml and checks a specific flag.
|
|
@@ -215,7 +171,7 @@ const plugin = {
|
|
|
215
171
|
}
|
|
216
172
|
|
|
217
173
|
// PRI-343: Check allowConversationAccess — warn if llm_output/trajectory hooks blocked
|
|
218
|
-
const accessCheck = checkConversationAccessConfig(api.
|
|
174
|
+
const accessCheck = checkConversationAccessConfig(getPluginEntry(api.config, api.id));
|
|
219
175
|
if (!accessCheck.authorized) {
|
|
220
176
|
api.logger.error(
|
|
221
177
|
`[PD:health] conversation hooks (llm_output / trajectory) will be BLOCKED by OpenClaw.\n` +
|
|
@@ -567,6 +523,31 @@ const plugin = {
|
|
|
567
523
|
return handleAfterCompaction(event, { ...ctx, workspaceDir: wsResult.workspaceDir });
|
|
568
524
|
}));
|
|
569
525
|
|
|
526
|
+
// ── Hook: Before Message Write (PRI-346) ──
|
|
527
|
+
// Fallback trajectory collection when llm_output is blocked by
|
|
528
|
+
// missing allowConversationAccess. Not in CONVERSATION_HOOK_NAMES
|
|
529
|
+
// so OpenClaw always delivers it.
|
|
530
|
+
api.on(
|
|
531
|
+
'before_message_write',
|
|
532
|
+
guardHook('hook:before_message_write', api.logger, (event: PluginHookBeforeMessageWriteEvent, ctx: PluginHookAgentContext): void => {
|
|
533
|
+
const wsResult = resolveHookWorkspaceDir(ctx, api, 'before_message_write');
|
|
534
|
+
if (!wsResult.ok) {
|
|
535
|
+
api.logger.warn(`[PD:before_message_write] workspaceDir resolution failed: ${wsResult.reason}`);
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
try {
|
|
539
|
+
TrajectoryCollector.handleBeforeMessageWrite(event, {
|
|
540
|
+
...ctx,
|
|
541
|
+
workspaceDir: wsResult.workspaceDir,
|
|
542
|
+
pluginConfig: getPluginEntry(api.config, api.id),
|
|
543
|
+
});
|
|
544
|
+
} catch (err) {
|
|
545
|
+
// Non-critical: don't surface to user
|
|
546
|
+
api.logger.warn(`[PD:before_message_write] error: ${String(err)}`);
|
|
547
|
+
}
|
|
548
|
+
})
|
|
549
|
+
);
|
|
550
|
+
|
|
570
551
|
// ── Service Registration (surface-guarded) ──
|
|
571
552
|
// PRI-294: EvolutionWorker service registration removed — it starts via
|
|
572
553
|
// before_prompt_build hook gate, not via api.registerService. The surface
|
package/src/openclaw-sdk.ts
CHANGED
|
@@ -125,6 +125,7 @@ describe('PRI-212 plugin core anti-growth guard', () => {
|
|
|
125
125
|
'workspace-guidance-migrator.ts',
|
|
126
126
|
'surface-guard.ts',
|
|
127
127
|
'pd-config-loader.ts',
|
|
128
|
+
'config-health.ts', // PRI-346: conversation access check extracted to avoid circular imports
|
|
128
129
|
] as const;
|
|
129
130
|
|
|
130
131
|
// Category 6: Test files
|
|
@@ -144,6 +144,7 @@ describe('PRI-294: Surface registry coverage audit', () => {
|
|
|
144
144
|
'hook:before_reset',
|
|
145
145
|
'hook:before_compaction',
|
|
146
146
|
'hook:after_compaction',
|
|
147
|
+
'hook:before_message_write', // PRI-346: SQLite fallback trajectory collection
|
|
147
148
|
// Services registered via guardService
|
|
148
149
|
'service:correction-observer',
|
|
149
150
|
'service:trajectory',
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trajectory Collector — before_message_write hook tests (PRI-346)
|
|
3
|
+
*
|
|
4
|
+
* Cases A–F verify:
|
|
5
|
+
* A: hook registration via api.on('before_message_write', ...)
|
|
6
|
+
* B: assistant message → SQLite fallback when unauthorized
|
|
7
|
+
* C: user message → user_turns; non-user/assistant → skip
|
|
8
|
+
* D: authorized → no SQLite write (de-duplication)
|
|
9
|
+
* E: CONVERSATION_HOOK_BLOCKED observability log
|
|
10
|
+
* F: privacy / path redaction in sanitized text
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
14
|
+
import type { OpenClawPluginApi, PluginHookBeforeMessageWriteEvent, PluginHookAgentContext } from '../../src/openclaw-sdk.js';
|
|
15
|
+
|
|
16
|
+
// Mock heavy dependencies before importing the module under test
|
|
17
|
+
const mockRecordAssistantTurn = vi.fn(() => 42);
|
|
18
|
+
const mockRecordUserTurn = vi.fn(() => 1);
|
|
19
|
+
|
|
20
|
+
vi.mock('../../src/core/workspace-context.js', () => {
|
|
21
|
+
return {
|
|
22
|
+
WorkspaceContext: {
|
|
23
|
+
fromHookContext: vi.fn(() => ({
|
|
24
|
+
trajectory: {
|
|
25
|
+
recordAssistantTurn: mockRecordAssistantTurn,
|
|
26
|
+
recordUserTurn: mockRecordUserTurn,
|
|
27
|
+
},
|
|
28
|
+
workspaceDir: '/mock/workspace',
|
|
29
|
+
stateDir: '/mock/workspace/.state',
|
|
30
|
+
})),
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
vi.mock('../../src/core/system-logger.js', () => ({
|
|
36
|
+
SystemLogger: {
|
|
37
|
+
log: vi.fn(),
|
|
38
|
+
},
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
vi.mock('../../src/hooks/message-sanitize.js', () => ({
|
|
42
|
+
sanitizeForEvidence: vi.fn((text: string, _wsDir?: string) => {
|
|
43
|
+
// Simulate path redaction: replace C:\Users\... patterns
|
|
44
|
+
return text.replace(/C:\\Users\\[^\s]+/gi, '[PATH_REDACTED]');
|
|
45
|
+
}),
|
|
46
|
+
}));
|
|
47
|
+
|
|
48
|
+
// fs mock: prevent real file I/O from writeTrajectoryRecord
|
|
49
|
+
vi.mock('fs', () => {
|
|
50
|
+
const memfs: Record<string, string> = {};
|
|
51
|
+
return {
|
|
52
|
+
existsSync: vi.fn(() => true),
|
|
53
|
+
mkdirSync: vi.fn(),
|
|
54
|
+
promises: {
|
|
55
|
+
mkdir: vi.fn(),
|
|
56
|
+
appendFile: vi.fn(async (filepath: string, data: string) => {
|
|
57
|
+
memfs[filepath] = (memfs[filepath] ?? '') + data;
|
|
58
|
+
}),
|
|
59
|
+
},
|
|
60
|
+
appendFile: vi.fn(),
|
|
61
|
+
__memfs: memfs,
|
|
62
|
+
};
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
import { handleBeforeMessageWrite } from '../../src/hooks/trajectory-collector.js';
|
|
66
|
+
import { WorkspaceContext } from '../../src/core/workspace-context.js';
|
|
67
|
+
import { SystemLogger } from '../../src/core/system-logger.js';
|
|
68
|
+
import plugin from '../../src/index.js';
|
|
69
|
+
|
|
70
|
+
function makeEvent(role: string, content: string | unknown[]): PluginHookBeforeMessageWriteEvent {
|
|
71
|
+
return {
|
|
72
|
+
message: { role, content },
|
|
73
|
+
sessionKey: 'sess-001',
|
|
74
|
+
sessionId: 'sess-001',
|
|
75
|
+
agentId: 'main',
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const unauthorizedConfig = { hooks: { allowConversationAccess: false } };
|
|
80
|
+
const authorizedConfig = { hooks: { allowConversationAccess: true } };
|
|
81
|
+
|
|
82
|
+
function makeCtx(pluginConfig: unknown, workspaceDir: string | null = '/mock/workspace') {
|
|
83
|
+
return {
|
|
84
|
+
workspaceDir: workspaceDir === null ? undefined : workspaceDir,
|
|
85
|
+
pluginConfig,
|
|
86
|
+
sessionId: 'sess-001',
|
|
87
|
+
agentId: 'main',
|
|
88
|
+
logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
89
|
+
} as PluginHookAgentContext & { workspaceDir?: string; pluginConfig?: unknown };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
describe('PRI-346: before_message_write hook', () => {
|
|
93
|
+
beforeEach(() => {
|
|
94
|
+
vi.clearAllMocks();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// ── Case A: hook registration ──────────────────────────────────────────────
|
|
98
|
+
describe('Case A — hook is registered', () => {
|
|
99
|
+
it('calls api.on with "before_message_write"', () => {
|
|
100
|
+
const onSpy = vi.fn();
|
|
101
|
+
const mockApi = {
|
|
102
|
+
rootDir: '/mock',
|
|
103
|
+
pluginConfig: { language: 'en' },
|
|
104
|
+
logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
105
|
+
config: {},
|
|
106
|
+
registerCommand: vi.fn(),
|
|
107
|
+
registerService: vi.fn(),
|
|
108
|
+
registerTool: vi.fn(),
|
|
109
|
+
registerHttpRoute: vi.fn(),
|
|
110
|
+
on: onSpy,
|
|
111
|
+
} as unknown as OpenClawPluginApi;
|
|
112
|
+
|
|
113
|
+
plugin.register(mockApi);
|
|
114
|
+
|
|
115
|
+
const registeredEvents = onSpy.mock.calls.map((c: unknown[]) => c[0]);
|
|
116
|
+
expect(registeredEvents).toContain('before_message_write');
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// ── Case B: assistant message → SQLite when unauthorized ───────────────────
|
|
121
|
+
describe('Case B — assistant message writes to SQLite when unauthorized', () => {
|
|
122
|
+
it('calls recordAssistantTurn once', () => {
|
|
123
|
+
const event = makeEvent('assistant', 'Hello, how can I help?');
|
|
124
|
+
const ctx = makeCtx(unauthorizedConfig);
|
|
125
|
+
|
|
126
|
+
handleBeforeMessageWrite(event, ctx);
|
|
127
|
+
|
|
128
|
+
expect(mockRecordAssistantTurn).toHaveBeenCalledTimes(1);
|
|
129
|
+
const call = mockRecordAssistantTurn.mock.calls[0][0];
|
|
130
|
+
expect(call.sessionId).toBe('sess-001');
|
|
131
|
+
expect(call.runId).toBe('before_message_write_fallback');
|
|
132
|
+
expect(call.provider).toBe('unknown');
|
|
133
|
+
expect(call.model).toBe('unknown');
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// ── Case C: user → user_turns; tool → skip ─────────────────────────────────
|
|
138
|
+
describe('Case C — user message and non-user/assistant', () => {
|
|
139
|
+
it('calls recordUserTurn for role=user', () => {
|
|
140
|
+
const event = makeEvent('user', 'Fix this bug please');
|
|
141
|
+
const ctx = makeCtx(unauthorizedConfig);
|
|
142
|
+
|
|
143
|
+
handleBeforeMessageWrite(event, ctx);
|
|
144
|
+
|
|
145
|
+
expect(mockRecordUserTurn).toHaveBeenCalledTimes(1);
|
|
146
|
+
expect(mockRecordAssistantTurn).not.toHaveBeenCalled();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('skips writing for role=tool', () => {
|
|
150
|
+
const event = makeEvent('tool', 'tool output here');
|
|
151
|
+
const ctx = makeCtx(unauthorizedConfig);
|
|
152
|
+
|
|
153
|
+
handleBeforeMessageWrite(event, ctx);
|
|
154
|
+
|
|
155
|
+
expect(mockRecordAssistantTurn).not.toHaveBeenCalled();
|
|
156
|
+
expect(mockRecordUserTurn).not.toHaveBeenCalled();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('handles array content (multipart messages)', () => {
|
|
160
|
+
const content = [
|
|
161
|
+
{ type: 'text', text: 'Part one' },
|
|
162
|
+
{ type: 'image_url', url: 'http://example.com/img.png' },
|
|
163
|
+
{ type: 'text', text: 'Part two' },
|
|
164
|
+
];
|
|
165
|
+
const event = makeEvent('assistant', content);
|
|
166
|
+
const ctx = makeCtx(unauthorizedConfig);
|
|
167
|
+
|
|
168
|
+
handleBeforeMessageWrite(event, ctx);
|
|
169
|
+
|
|
170
|
+
expect(mockRecordAssistantTurn).toHaveBeenCalledTimes(1);
|
|
171
|
+
const call = mockRecordAssistantTurn.mock.calls[0][0];
|
|
172
|
+
expect(call.rawText).toContain('Part one');
|
|
173
|
+
expect(call.rawText).toContain('Part two');
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// ── Case D: de-duplication (authorized → no SQLite) ────────────────────────
|
|
178
|
+
describe('Case D — authorized config skips SQLite (de-dup)', () => {
|
|
179
|
+
it('does NOT call recordAssistantTurn when authorized', () => {
|
|
180
|
+
const event = makeEvent('assistant', 'Normal response');
|
|
181
|
+
const ctx = makeCtx(authorizedConfig);
|
|
182
|
+
|
|
183
|
+
handleBeforeMessageWrite(event, ctx);
|
|
184
|
+
|
|
185
|
+
expect(mockRecordAssistantTurn).not.toHaveBeenCalled();
|
|
186
|
+
expect(mockRecordUserTurn).not.toHaveBeenCalled();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('does NOT call SystemLogger.log when authorized', () => {
|
|
190
|
+
const event = makeEvent('assistant', 'Normal response');
|
|
191
|
+
const ctx = makeCtx(authorizedConfig);
|
|
192
|
+
|
|
193
|
+
handleBeforeMessageWrite(event, ctx);
|
|
194
|
+
|
|
195
|
+
expect(SystemLogger.log).not.toHaveBeenCalled();
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// ── Case E: CONVERSATION_HOOK_BLOCKED observability ─────────────────────────
|
|
200
|
+
describe('Case E — CONVERSATION_HOOK_BLOCKED logged when unauthorized', () => {
|
|
201
|
+
it('logs with reason + nextAction', () => {
|
|
202
|
+
const event = makeEvent('assistant', 'Some response');
|
|
203
|
+
const ctx = makeCtx(unauthorizedConfig);
|
|
204
|
+
|
|
205
|
+
handleBeforeMessageWrite(event, ctx);
|
|
206
|
+
|
|
207
|
+
expect(SystemLogger.log).toHaveBeenCalledWith(
|
|
208
|
+
'/mock/workspace',
|
|
209
|
+
'CONVERSATION_HOOK_BLOCKED',
|
|
210
|
+
expect.any(String),
|
|
211
|
+
);
|
|
212
|
+
const payload = JSON.parse(
|
|
213
|
+
(SystemLogger.log as ReturnType<typeof vi.fn>).mock.calls[0][2] as string
|
|
214
|
+
);
|
|
215
|
+
expect(payload.reason).toBeDefined();
|
|
216
|
+
expect(payload.nextAction).toBeDefined();
|
|
217
|
+
expect(payload.hook).toBe('llm_output');
|
|
218
|
+
expect(payload.fallback).toBe('before_message_write');
|
|
219
|
+
expect(payload.role).toBe('assistant');
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// ── Case F: privacy / path redaction ────────────────────────────────────────
|
|
224
|
+
describe('Case F — sensitive path is redacted in sanitizedText', () => {
|
|
225
|
+
it('sanitizedText does not contain the raw path', () => {
|
|
226
|
+
const sensitiveContent = 'The file is at C:\\Users\\sensitive\\path\\secret.txt please check it';
|
|
227
|
+
const event = makeEvent('assistant', sensitiveContent);
|
|
228
|
+
const ctx = makeCtx(unauthorizedConfig);
|
|
229
|
+
|
|
230
|
+
handleBeforeMessageWrite(event, ctx);
|
|
231
|
+
|
|
232
|
+
expect(mockRecordAssistantTurn).toHaveBeenCalledTimes(1);
|
|
233
|
+
const call = mockRecordAssistantTurn.mock.calls[0][0];
|
|
234
|
+
expect(call.sanitizedText).not.toContain('C:\\Users\\sensitive\\path');
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// ── Edge cases ─────────────────────────────────────────────────────────────
|
|
239
|
+
describe('Edge cases', () => {
|
|
240
|
+
it('returns early when workspaceDir is missing', () => {
|
|
241
|
+
mockRecordAssistantTurn.mockReset();
|
|
242
|
+
mockRecordUserTurn.mockReset();
|
|
243
|
+
const event = makeEvent('assistant', 'Hello');
|
|
244
|
+
const ctx = makeCtx(unauthorizedConfig, null);
|
|
245
|
+
|
|
246
|
+
// Should not throw
|
|
247
|
+
handleBeforeMessageWrite(event, ctx);
|
|
248
|
+
expect(mockRecordAssistantTurn).not.toHaveBeenCalled();
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('returns early when message is null/undefined', () => {
|
|
252
|
+
const event = { message: null as unknown as { role?: string }, sessionKey: 's' } as PluginHookBeforeMessageWriteEvent;
|
|
253
|
+
const ctx = makeCtx(unauthorizedConfig);
|
|
254
|
+
|
|
255
|
+
handleBeforeMessageWrite(event, ctx);
|
|
256
|
+
expect(mockRecordAssistantTurn).not.toHaveBeenCalled();
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('handles missing pluginConfig gracefully', () => {
|
|
260
|
+
const event = makeEvent('assistant', 'Hello');
|
|
261
|
+
const ctx = makeCtx(undefined);
|
|
262
|
+
|
|
263
|
+
handleBeforeMessageWrite(event, ctx);
|
|
264
|
+
|
|
265
|
+
// pluginConfig undefined → unauthorized → fallback fires
|
|
266
|
+
expect(mockRecordAssistantTurn).toHaveBeenCalledTimes(1);
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
});
|