principles-disciple 1.83.1 → 1.85.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/event-log.ts +86 -1
- package/src/hooks/gate.ts +3 -0
- package/src/index.ts +57 -39
- package/src/utils/workspace-resolver.ts +250 -2
- package/tests/core/event-log.test.ts +166 -0
- package/tests/hook-workspace-nextaction-contract.test.ts +32 -24
- package/tests/utils/hook-workspace-resolver.test.ts +347 -0
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
package/src/core/event-log.ts
CHANGED
|
@@ -32,6 +32,7 @@ import type {
|
|
|
32
32
|
import { createEmptyDailyStats } from '../types/event-types.js';
|
|
33
33
|
import { atomicWriteFileSync } from '../utils/io.js';
|
|
34
34
|
import type { PluginLogger } from '../openclaw-sdk.js';
|
|
35
|
+
import { redactTelemetryString } from '@principles/core/runtime-v2';
|
|
35
36
|
|
|
36
37
|
const EVENT_LOG_RETENTION_DAYS = 7;
|
|
37
38
|
|
|
@@ -209,6 +210,88 @@ export class EventLog {
|
|
|
209
210
|
this.record('runtime_v2_prompt_activations_injected', 'injected', data.sessionId, data);
|
|
210
211
|
}
|
|
211
212
|
|
|
213
|
+
/**
|
|
214
|
+
* Redact telemetry-sensitive string values in event data before persistence.
|
|
215
|
+
* Applies redactTelemetryString to known high-risk fields (filePath, command,
|
|
216
|
+
* reason, args, new_string, old_string, text, paramsSummary values) and to all
|
|
217
|
+
* string values in tool_call/rulehost_* data as a safety net.
|
|
218
|
+
*
|
|
219
|
+
* ERR-002: never throws; returns data unchanged on error.
|
|
220
|
+
* ERR-045/046: covers composite command strings, Authorization headers, env vars.
|
|
221
|
+
*/
|
|
222
|
+
private redactEventData(
|
|
223
|
+
type: EventType,
|
|
224
|
+
data: Record<string, unknown>
|
|
225
|
+
): Record<string, unknown> {
|
|
226
|
+
try {
|
|
227
|
+
// Known high-risk event types — telemetry that carries tool commands / paths
|
|
228
|
+
const telemetryTypes: Set<EventType> = new Set([
|
|
229
|
+
'tool_call',
|
|
230
|
+
'rulehost_evaluated',
|
|
231
|
+
'rulehost_blocked',
|
|
232
|
+
'rulehost_requireApproval',
|
|
233
|
+
'rulehost_auto_correct_proposed',
|
|
234
|
+
'rulehost_auto_correct_applied',
|
|
235
|
+
'rule_enforced',
|
|
236
|
+
'hook_execution',
|
|
237
|
+
'gate_block',
|
|
238
|
+
'gate_bypass',
|
|
239
|
+
]);
|
|
240
|
+
|
|
241
|
+
if (!telemetryTypes.has(type)) return data;
|
|
242
|
+
|
|
243
|
+
const redacted: Record<string, unknown> = {};
|
|
244
|
+
for (const [key, value] of Object.entries(data)) {
|
|
245
|
+
if (typeof value === 'string') {
|
|
246
|
+
redacted[key] = redactTelemetryString(value);
|
|
247
|
+
} else if (Array.isArray(value)) {
|
|
248
|
+
// Recurse into arrays (e.g. correctedFields with original/applied)
|
|
249
|
+
redacted[key] = value.map((item: unknown) => {
|
|
250
|
+
if (typeof item === 'string') {
|
|
251
|
+
return redactTelemetryString(item);
|
|
252
|
+
}
|
|
253
|
+
if (typeof item === 'object' && item !== null) {
|
|
254
|
+
const nested: Record<string, unknown> = {};
|
|
255
|
+
for (const [nk, nv] of Object.entries(item as Record<string, unknown>)) {
|
|
256
|
+
nested[nk] = typeof nv === 'string' ? redactTelemetryString(nv) : nv;
|
|
257
|
+
}
|
|
258
|
+
return nested;
|
|
259
|
+
}
|
|
260
|
+
return item;
|
|
261
|
+
});
|
|
262
|
+
} else if (typeof value === 'object' && value !== null) {
|
|
263
|
+
// Recurse one level for nested objects (e.g. paramsSummary)
|
|
264
|
+
const nested: Record<string, unknown> = {};
|
|
265
|
+
for (const [nk, nv] of Object.entries(value as Record<string, unknown>)) {
|
|
266
|
+
if (typeof nv === 'string') {
|
|
267
|
+
nested[nk] = redactTelemetryString(nv);
|
|
268
|
+
} else {
|
|
269
|
+
nested[nk] = nv;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
redacted[key] = nested;
|
|
273
|
+
} else {
|
|
274
|
+
redacted[key] = value;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return redacted;
|
|
278
|
+
} catch (e) {
|
|
279
|
+
// ERR-002: fail safe — never write raw payload on redaction failure.
|
|
280
|
+
// Return a masked payload with context so downstream knows what happened.
|
|
281
|
+
const errStr = e instanceof Error ? e.message.slice(0, 200) : String(e).slice(0, 200);
|
|
282
|
+
const masked: Record<string, unknown> = {
|
|
283
|
+
redactionFailure: true,
|
|
284
|
+
redactionStatus: 'failed',
|
|
285
|
+
'redaction.status': 'failed',
|
|
286
|
+
redactionReason: errStr || 'unknown error',
|
|
287
|
+
redactionDataDropped: true,
|
|
288
|
+
originalType: type,
|
|
289
|
+
originalSessionId: data.sessionId ?? null,
|
|
290
|
+
};
|
|
291
|
+
return masked;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
212
295
|
private record(
|
|
213
296
|
type: EventType,
|
|
214
297
|
category: EventCategory,
|
|
@@ -218,13 +301,15 @@ export class EventLog {
|
|
|
218
301
|
const now = new Date();
|
|
219
302
|
const date = this.formatDate(now);
|
|
220
303
|
|
|
304
|
+
const redactedData = this.redactEventData(type, data as Record<string, unknown>);
|
|
305
|
+
|
|
221
306
|
const entry: EventLogEntry = {
|
|
222
307
|
ts: now.toISOString(),
|
|
223
308
|
date,
|
|
224
309
|
type,
|
|
225
310
|
category,
|
|
226
311
|
sessionId,
|
|
227
|
-
data:
|
|
312
|
+
data: redactedData,
|
|
228
313
|
};
|
|
229
314
|
|
|
230
315
|
this.eventBuffer.push(entry);
|
package/src/hooks/gate.ts
CHANGED
|
@@ -339,6 +339,9 @@ export function handleBeforeToolCall(
|
|
|
339
339
|
|
|
340
340
|
function _extractParamsSummary(params: Record<string, unknown>): Record<string, unknown> {
|
|
341
341
|
const summary: Record<string, unknown> = {};
|
|
342
|
+
// NOTE: Do NOT redact here — this feeds into RuleHost.evaluate() which
|
|
343
|
+
// may match against paramsSummary.command. Redaction happens at
|
|
344
|
+
// EventLog.record() before persistence.
|
|
342
345
|
if (params.file_path) summary.file_path = params.file_path;
|
|
343
346
|
if (params.path) summary.path = params.path;
|
|
344
347
|
if (params.command) summary.command = params.command;
|
package/src/index.ts
CHANGED
|
@@ -58,7 +58,7 @@ import { migrateDirectoryStructure } from './core/migration.js';
|
|
|
58
58
|
import { migrateStaleWorkspaceGuidance } from './core/workspace-guidance-migrator.js';
|
|
59
59
|
import { SystemLogger } from './core/system-logger.js';
|
|
60
60
|
import { PathResolver } from './core/path-resolver.js';
|
|
61
|
-
import { resolveCommandWorkspaceDir, resolveToolHookWorkspaceDirSafe } from './utils/workspace-resolver.js';
|
|
61
|
+
import { resolveCommandWorkspaceDir, resolveToolHookWorkspaceDirSafe, resolveHookWorkspaceDir } from './utils/workspace-resolver.js';
|
|
62
62
|
import { computeRuntimeShadowTaskFingerprint, PD_LOCAL_PROFILES } from './utils/shadow-fingerprint.js';
|
|
63
63
|
import type { WorkerProfile } from './core/model-deployment-registry.js';
|
|
64
64
|
import { validateWorkspaceDir } from './core/workspace-dir-validation.js';
|
|
@@ -68,10 +68,6 @@ import { checkSurfaceGuard, guardHook, guardService } from './core/surface-guard
|
|
|
68
68
|
// Track started workspaces — one-time init + evolution worker per workspace
|
|
69
69
|
const startedWorkspaces = new Set<string>();
|
|
70
70
|
|
|
71
|
-
const HOOK_WORKSPACE_RESOLUTION_NEXT_ACTION =
|
|
72
|
-
'verify gateway plugin activation and hook workspace binding; ' +
|
|
73
|
-
'migrate live hook workspace resolution to PD-owned canonical configuration before relying on config-based recovery';
|
|
74
|
-
|
|
75
71
|
// Map from childSessionKey → shadowObservationId
|
|
76
72
|
// Used to complete shadow observations when subagent ends
|
|
77
73
|
const pendingShadowObservations = new Map<string, string>();
|
|
@@ -235,17 +231,20 @@ const plugin = {
|
|
|
235
231
|
api.on(
|
|
236
232
|
'before_prompt_build',
|
|
237
233
|
guardHook('hook:before_prompt_build', api.logger, async (event: PluginHookBeforePromptBuildEvent, ctx: PluginHookAgentContext): Promise<PluginHookBeforePromptBuildResult | void> => {
|
|
238
|
-
const
|
|
239
|
-
if (!
|
|
234
|
+
const wsResult = resolveHookWorkspaceDir(ctx, api, 'before_prompt_build');
|
|
235
|
+
if (!wsResult.ok) {
|
|
240
236
|
api.logger.error(
|
|
241
237
|
`[PD:before_prompt_build] workspaceDir resolution failed. ` +
|
|
242
|
-
`agentId=${ctx.agentId ?? '(missing)'} sessionId=${ctx.sessionId ?? '(missing)'} ` +
|
|
243
|
-
`sessionKey=${ctx.sessionKey ?? '(missing)'}. ` +
|
|
238
|
+
`reason=${wsResult.reason} agentId=${ctx.agentId ?? '(missing)'} sessionId=${ctx.sessionId ?? '(missing)'}. ` +
|
|
244
239
|
`Hook skipped — no mutation will occur. ` +
|
|
245
|
-
`NextAction: ${
|
|
240
|
+
`NextAction: ${wsResult.nextAction}`,
|
|
246
241
|
);
|
|
247
242
|
return;
|
|
248
243
|
}
|
|
244
|
+
const workspaceDir = wsResult.workspaceDir;
|
|
245
|
+
if (wsResult.consistencyWarning) {
|
|
246
|
+
api.logger.warn(`[PD:before_prompt_build] ${wsResult.consistencyWarning}`);
|
|
247
|
+
}
|
|
249
248
|
try {
|
|
250
249
|
if (!startedWorkspaces.has(workspaceDir)) {
|
|
251
250
|
startedWorkspaces.add(workspaceDir);
|
|
@@ -313,17 +312,20 @@ const plugin = {
|
|
|
313
312
|
api.on(
|
|
314
313
|
'before_tool_call',
|
|
315
314
|
guardHook('hook:before_tool_call', api.logger, (event: PluginHookBeforeToolCallEvent, ctx: PluginHookToolContext): PluginHookBeforeToolCallResult | void => {
|
|
316
|
-
const
|
|
317
|
-
if (!
|
|
315
|
+
const wsResult = resolveHookWorkspaceDir(ctx, api, 'before_tool_call');
|
|
316
|
+
if (!wsResult.ok) {
|
|
318
317
|
api.logger.error(
|
|
319
318
|
`[PD:before_tool_call] workspaceDir resolution failed. ` +
|
|
320
|
-
`agentId=${ctx.agentId ?? '(missing)'} sessionId=${ctx.sessionId ?? '(missing)'} ` +
|
|
321
|
-
`sessionKey=${ctx.sessionKey ?? '(missing)'}. ` +
|
|
319
|
+
`reason=${wsResult.reason} agentId=${ctx.agentId ?? '(missing)'} sessionId=${ctx.sessionId ?? '(missing)'}. ` +
|
|
322
320
|
`Hook skipped — security gate bypassed. ` +
|
|
323
|
-
`NextAction: ${
|
|
321
|
+
`NextAction: ${wsResult.nextAction}`,
|
|
324
322
|
);
|
|
325
323
|
return;
|
|
326
324
|
}
|
|
325
|
+
const workspaceDir = wsResult.workspaceDir;
|
|
326
|
+
if (wsResult.consistencyWarning) {
|
|
327
|
+
api.logger.warn(`[PD:before_tool_call] ${wsResult.consistencyWarning}`);
|
|
328
|
+
}
|
|
327
329
|
try {
|
|
328
330
|
const pluginConfig = api.pluginConfig ?? {};
|
|
329
331
|
const {logger} = api;
|
|
@@ -348,17 +350,20 @@ const plugin = {
|
|
|
348
350
|
api.on(
|
|
349
351
|
'after_tool_call',
|
|
350
352
|
guardHook('hook:after_tool_call', api.logger, (event: PluginHookAfterToolCallEvent, ctx: PluginHookToolContext): void => {
|
|
351
|
-
const
|
|
352
|
-
if (!
|
|
353
|
+
const wsResult = resolveHookWorkspaceDir(ctx, api, 'after_tool_call');
|
|
354
|
+
if (!wsResult.ok) {
|
|
353
355
|
api.logger.error(
|
|
354
356
|
`[PD:after_tool_call] workspaceDir resolution failed. ` +
|
|
355
|
-
`agentId=${ctx.agentId ?? '(missing)'} sessionId=${ctx.sessionId ?? '(missing)'} ` +
|
|
356
|
-
`sessionKey=${ctx.sessionKey ?? '(missing)'}. ` +
|
|
357
|
+
`reason=${wsResult.reason} agentId=${ctx.agentId ?? '(missing)'} sessionId=${ctx.sessionId ?? '(missing)'}. ` +
|
|
357
358
|
`Hook skipped — pain detection bypassed. ` +
|
|
358
|
-
`NextAction: ${
|
|
359
|
+
`NextAction: ${wsResult.nextAction}`,
|
|
359
360
|
);
|
|
360
361
|
return;
|
|
361
362
|
}
|
|
363
|
+
const workspaceDir = wsResult.workspaceDir;
|
|
364
|
+
if (wsResult.consistencyWarning) {
|
|
365
|
+
api.logger.warn(`[PD:after_tool_call] ${wsResult.consistencyWarning}`);
|
|
366
|
+
}
|
|
362
367
|
try {
|
|
363
368
|
const pluginConfig = api.pluginConfig ?? {};
|
|
364
369
|
// Pass api separately to handleAfterToolCall to maintain type safety
|
|
@@ -381,17 +386,21 @@ const plugin = {
|
|
|
381
386
|
api.on(
|
|
382
387
|
'llm_output',
|
|
383
388
|
guardHook('hook:llm_output', api.logger, (event: PluginHookLlmOutputEvent, ctx: PluginHookAgentContext): void => {
|
|
384
|
-
const
|
|
385
|
-
if (!
|
|
389
|
+
const wsResult = resolveHookWorkspaceDir(ctx, api, 'llm_output');
|
|
390
|
+
if (!wsResult.ok) {
|
|
386
391
|
api.logger.error(
|
|
387
392
|
`[PD:llm_output] workspaceDir resolution failed. ` +
|
|
388
|
-
`agentId=${ctx.agentId ?? '(missing)'} ` +
|
|
393
|
+
`reason=${wsResult.reason} agentId=${ctx.agentId ?? '(missing)'} ` +
|
|
389
394
|
`sessionId=${ctx.sessionId ?? '(missing)'}. ` +
|
|
390
395
|
`Hook skipped — LLM analysis bypassed. ` +
|
|
391
|
-
`NextAction: ${
|
|
396
|
+
`NextAction: ${wsResult.nextAction}`,
|
|
392
397
|
);
|
|
393
398
|
return;
|
|
394
399
|
}
|
|
400
|
+
const workspaceDir = wsResult.workspaceDir;
|
|
401
|
+
if (wsResult.consistencyWarning) {
|
|
402
|
+
api.logger.warn(`[PD:llm_output] ${wsResult.consistencyWarning}`);
|
|
403
|
+
}
|
|
395
404
|
try {
|
|
396
405
|
handleLlmOutput(event, { ...ctx, workspaceDir });
|
|
397
406
|
|
|
@@ -525,42 +534,51 @@ const plugin = {
|
|
|
525
534
|
|
|
526
535
|
// ── Hook: Lifecycle ──
|
|
527
536
|
api.on('before_reset', guardHook('hook:before_reset', api.logger, (event: PluginHookBeforeResetEvent, ctx: PluginHookAgentContext) => {
|
|
528
|
-
const
|
|
529
|
-
if (!
|
|
537
|
+
const wsResult = resolveHookWorkspaceDir(ctx, api, 'before_reset');
|
|
538
|
+
if (!wsResult.ok) {
|
|
530
539
|
api.logger.error(
|
|
531
540
|
`[PD:before_reset] workspaceDir resolution failed. ` +
|
|
532
|
-
`agentId=${ctx.agentId ?? '(missing)'} sessionId=${ctx.sessionId ?? '(missing)'}. ` +
|
|
533
|
-
`Hook skipped. NextAction: ${
|
|
541
|
+
`reason=${wsResult.reason} agentId=${ctx.agentId ?? '(missing)'} sessionId=${ctx.sessionId ?? '(missing)'}. ` +
|
|
542
|
+
`Hook skipped. NextAction: ${wsResult.nextAction}`,
|
|
534
543
|
);
|
|
535
544
|
return;
|
|
536
545
|
}
|
|
537
|
-
|
|
546
|
+
if (wsResult.consistencyWarning) {
|
|
547
|
+
api.logger.warn(`[PD:before_reset] ${wsResult.consistencyWarning}`);
|
|
548
|
+
}
|
|
549
|
+
return handleBeforeReset(event, { ...ctx, workspaceDir: wsResult.workspaceDir });
|
|
538
550
|
}));
|
|
539
551
|
|
|
540
552
|
api.on('before_compaction', guardHook('hook:before_compaction', api.logger, (event: PluginHookBeforeCompactionEvent, ctx: PluginHookAgentContext) => {
|
|
541
|
-
const
|
|
542
|
-
if (!
|
|
553
|
+
const wsResult = resolveHookWorkspaceDir(ctx, api, 'before_compaction');
|
|
554
|
+
if (!wsResult.ok) {
|
|
543
555
|
api.logger.error(
|
|
544
556
|
`[PD:before_compaction] workspaceDir resolution failed. ` +
|
|
545
|
-
`agentId=${ctx.agentId ?? '(missing)'} sessionId=${ctx.sessionId ?? '(missing)'}. ` +
|
|
546
|
-
`Hook skipped. NextAction: ${
|
|
557
|
+
`reason=${wsResult.reason} agentId=${ctx.agentId ?? '(missing)'} sessionId=${ctx.sessionId ?? '(missing)'}. ` +
|
|
558
|
+
`Hook skipped. NextAction: ${wsResult.nextAction}`,
|
|
547
559
|
);
|
|
548
560
|
return;
|
|
549
561
|
}
|
|
550
|
-
|
|
562
|
+
if (wsResult.consistencyWarning) {
|
|
563
|
+
api.logger.warn(`[PD:before_compaction] ${wsResult.consistencyWarning}`);
|
|
564
|
+
}
|
|
565
|
+
return handleBeforeCompaction(event, { ...ctx, workspaceDir: wsResult.workspaceDir });
|
|
551
566
|
}));
|
|
552
567
|
|
|
553
568
|
api.on('after_compaction', guardHook('hook:after_compaction', api.logger, (event: PluginHookAfterCompactionEvent, ctx: PluginHookAgentContext) => {
|
|
554
|
-
const
|
|
555
|
-
if (!
|
|
569
|
+
const wsResult = resolveHookWorkspaceDir(ctx, api, 'after_compaction');
|
|
570
|
+
if (!wsResult.ok) {
|
|
556
571
|
api.logger.error(
|
|
557
572
|
`[PD:after_compaction] workspaceDir resolution failed. ` +
|
|
558
|
-
`agentId=${ctx.agentId ?? '(missing)'} sessionId=${ctx.sessionId ?? '(missing)'}. ` +
|
|
559
|
-
`Hook skipped. NextAction: ${
|
|
573
|
+
`reason=${wsResult.reason} agentId=${ctx.agentId ?? '(missing)'} sessionId=${ctx.sessionId ?? '(missing)'}. ` +
|
|
574
|
+
`Hook skipped. NextAction: ${wsResult.nextAction}`,
|
|
560
575
|
);
|
|
561
576
|
return;
|
|
562
577
|
}
|
|
563
|
-
|
|
578
|
+
if (wsResult.consistencyWarning) {
|
|
579
|
+
api.logger.warn(`[PD:after_compaction] ${wsResult.consistencyWarning}`);
|
|
580
|
+
}
|
|
581
|
+
return handleAfterCompaction(event, { ...ctx, workspaceDir: wsResult.workspaceDir });
|
|
564
582
|
}));
|
|
565
583
|
|
|
566
584
|
// ── Service Registration (surface-guarded) ──
|
|
@@ -2,13 +2,19 @@
|
|
|
2
2
|
* Workspace Directory Resolution Utilities
|
|
3
3
|
*
|
|
4
4
|
* Shared helpers for resolving workspace directories across commands and hooks.
|
|
5
|
+
*
|
|
6
|
+
* Hook resolution priority (PRI-259): PD canonical config → OpenClaw fallback.
|
|
7
|
+
* PD canonical sources: PD_WORKSPACE_DIR env → OPENCLAW_WORKSPACE env →
|
|
8
|
+
* principles-disciple.json → ~/.openclaw/workspace default.
|
|
9
|
+
* OpenClaw fallback: ctx.workspaceDir → api.runtime.agent.resolveAgentWorkspaceDir().
|
|
5
10
|
*/
|
|
6
11
|
|
|
7
12
|
import type { OpenClawPluginApi, PluginCommandContext } from '../openclaw-sdk.js';
|
|
8
13
|
import { validateWorkspaceDir, type WorkspaceResolutionContext } from '../core/workspace-dir-validation.js';
|
|
9
|
-
import { resolveWorkspaceDir } from '../core/workspace-dir-service.js';
|
|
10
14
|
import { resolveWorkspaceDirFromApi } from '../core/path-resolver.js';
|
|
11
15
|
import * as path from 'path';
|
|
16
|
+
import * as os from 'os';
|
|
17
|
+
import * as fs from 'fs';
|
|
12
18
|
|
|
13
19
|
/**
|
|
14
20
|
* Resolve workspace directory for command execution.
|
|
@@ -83,16 +89,258 @@ export function resolvePluginCommandWorkspaceDir(
|
|
|
83
89
|
);
|
|
84
90
|
}
|
|
85
91
|
|
|
92
|
+
// ── PD Canonical Workspace Config Resolution (PRI-259) ──────────────────
|
|
93
|
+
|
|
94
|
+
export type CanonicalWorkspaceSource = 'pd_env' | 'openclaw_env' | 'pd_config' | 'pd_default';
|
|
95
|
+
|
|
96
|
+
export interface CanonicalWorkspaceResult {
|
|
97
|
+
workspaceDir: string;
|
|
98
|
+
source: CanonicalWorkspaceSource;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const PD_CONFIG_FILENAME = 'principles-disciple.json';
|
|
102
|
+
|
|
103
|
+
function loadWorkspaceFromPdConfigFile(): string | null {
|
|
104
|
+
const candidates = [
|
|
105
|
+
path.join(os.homedir(), '.openclaw', PD_CONFIG_FILENAME),
|
|
106
|
+
path.join(os.homedir(), '.principles', PD_CONFIG_FILENAME),
|
|
107
|
+
path.join(process.cwd(), PD_CONFIG_FILENAME),
|
|
108
|
+
];
|
|
109
|
+
|
|
110
|
+
for (const configPath of candidates) {
|
|
111
|
+
if (!fs.existsSync(configPath)) continue;
|
|
112
|
+
try {
|
|
113
|
+
const raw = fs.readFileSync(configPath, 'utf8');
|
|
114
|
+
const parsed: unknown = JSON.parse(raw);
|
|
115
|
+
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
116
|
+
if (Object.hasOwn(parsed, 'workspace')) {
|
|
117
|
+
const workspaceValue = (parsed as Record<string, unknown>)['workspace'];
|
|
118
|
+
if (typeof workspaceValue === 'string' && workspaceValue.trim()) {
|
|
119
|
+
return workspaceValue.trim();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
} catch {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function resolveCanonicalWorkspaceDir(): CanonicalWorkspaceResult | null {
|
|
131
|
+
const pdEnv = process.env.PD_WORKSPACE_DIR;
|
|
132
|
+
if (pdEnv && pdEnv.trim()) {
|
|
133
|
+
const dir = path.resolve(pdEnv.trim());
|
|
134
|
+
if (!validateWorkspaceDir(dir)) {
|
|
135
|
+
return { workspaceDir: dir, source: 'pd_env' };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const ocEnv = process.env.OPENCLAW_WORKSPACE;
|
|
140
|
+
if (ocEnv && ocEnv.trim()) {
|
|
141
|
+
const dir = path.resolve(ocEnv.trim());
|
|
142
|
+
if (!validateWorkspaceDir(dir)) {
|
|
143
|
+
return { workspaceDir: dir, source: 'openclaw_env' };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const configWorkspace = loadWorkspaceFromPdConfigFile();
|
|
148
|
+
if (configWorkspace) {
|
|
149
|
+
const dir = path.resolve(configWorkspace);
|
|
150
|
+
if (!validateWorkspaceDir(dir)) {
|
|
151
|
+
return { workspaceDir: dir, source: 'pd_config' };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const defaultDir = path.join(os.homedir(), '.openclaw', 'workspace');
|
|
156
|
+
if (!validateWorkspaceDir(defaultDir)) {
|
|
157
|
+
return { workspaceDir: defaultDir, source: 'pd_default' };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Resolve only PD explicit sources (env vars + config file), excluding pd_default.
|
|
165
|
+
* Used by hook resolution to ensure ctx.workspaceDir takes priority over the
|
|
166
|
+
* hardcoded default fallback.
|
|
167
|
+
*/
|
|
168
|
+
function resolveExplicitPdSources(): CanonicalWorkspaceResult | null {
|
|
169
|
+
const pdEnv = process.env.PD_WORKSPACE_DIR;
|
|
170
|
+
if (pdEnv && pdEnv.trim()) {
|
|
171
|
+
const dir = path.resolve(pdEnv.trim());
|
|
172
|
+
if (!validateWorkspaceDir(dir)) {
|
|
173
|
+
return { workspaceDir: dir, source: 'pd_env' };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const ocEnv = process.env.OPENCLAW_WORKSPACE;
|
|
178
|
+
if (ocEnv && ocEnv.trim()) {
|
|
179
|
+
const dir = path.resolve(ocEnv.trim());
|
|
180
|
+
if (!validateWorkspaceDir(dir)) {
|
|
181
|
+
return { workspaceDir: dir, source: 'openclaw_env' };
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const configWorkspace = loadWorkspaceFromPdConfigFile();
|
|
186
|
+
if (configWorkspace) {
|
|
187
|
+
const dir = path.resolve(configWorkspace);
|
|
188
|
+
if (!validateWorkspaceDir(dir)) {
|
|
189
|
+
return { workspaceDir: dir, source: 'pd_config' };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ── Hook Workspace Resolution (PRI-259) ────────────────────────────────
|
|
197
|
+
|
|
198
|
+
export type HookWorkspaceSource = CanonicalWorkspaceSource | 'openclaw_context' | 'openclaw_api';
|
|
199
|
+
|
|
200
|
+
export interface HookWorkspaceResolutionSuccess {
|
|
201
|
+
ok: true;
|
|
202
|
+
workspaceDir: string;
|
|
203
|
+
source: HookWorkspaceSource;
|
|
204
|
+
consistencyWarning?: string;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export interface HookWorkspaceResolutionFailure {
|
|
208
|
+
ok: false;
|
|
209
|
+
reason: string;
|
|
210
|
+
nextAction: string;
|
|
211
|
+
message: string;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export type HookWorkspaceResolutionResult =
|
|
215
|
+
| HookWorkspaceResolutionSuccess
|
|
216
|
+
| HookWorkspaceResolutionFailure;
|
|
217
|
+
|
|
218
|
+
function tryResolveFromOpenClawApi(
|
|
219
|
+
api: OpenClawPluginApi,
|
|
220
|
+
agentId: string | undefined,
|
|
221
|
+
): string | undefined {
|
|
222
|
+
try {
|
|
223
|
+
const resolved = api.runtime?.agent?.resolveAgentWorkspaceDir?.(api.config, agentId ?? 'main');
|
|
224
|
+
if (resolved && !validateWorkspaceDir(resolved)) {
|
|
225
|
+
return resolved;
|
|
226
|
+
}
|
|
227
|
+
} catch {
|
|
228
|
+
// Fall through
|
|
229
|
+
}
|
|
230
|
+
return undefined;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export interface HookWorkspaceResolutionOptions {
|
|
234
|
+
canonicalResolver?: () => CanonicalWorkspaceResult | null;
|
|
235
|
+
explicitPdResolver?: () => CanonicalWorkspaceResult | null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function resolveHookWorkspaceDir(
|
|
239
|
+
ctx: WorkspaceResolutionContext,
|
|
240
|
+
api: OpenClawPluginApi,
|
|
241
|
+
source: string,
|
|
242
|
+
options?: HookWorkspaceResolutionOptions,
|
|
243
|
+
): HookWorkspaceResolutionResult {
|
|
244
|
+
// Priority 1: PD explicit sources (env vars + config file) — these are
|
|
245
|
+
// owner-declared and intentionally override the live session context.
|
|
246
|
+
const resolveExplicit = options?.explicitPdResolver ?? resolveExplicitPdSources;
|
|
247
|
+
const explicit = resolveExplicit();
|
|
248
|
+
|
|
249
|
+
if (explicit) {
|
|
250
|
+
let consistencyWarning: string | undefined;
|
|
251
|
+
|
|
252
|
+
if (ctx.workspaceDir) {
|
|
253
|
+
const normalizedCtx = path.resolve(ctx.workspaceDir);
|
|
254
|
+
const normalizedExplicit = path.resolve(explicit.workspaceDir);
|
|
255
|
+
if (normalizedCtx !== normalizedExplicit) {
|
|
256
|
+
consistencyWarning =
|
|
257
|
+
`PD explicit workspace (${explicit.source}: ${explicit.workspaceDir}) ` +
|
|
258
|
+
`differs from OpenClaw context (${ctx.workspaceDir}). Using PD explicit.`;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
ok: true,
|
|
264
|
+
workspaceDir: explicit.workspaceDir,
|
|
265
|
+
source: explicit.source,
|
|
266
|
+
consistencyWarning,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Priority 2: OpenClaw live context — the real session workspace.
|
|
271
|
+
// This MUST take priority over pd_default (the hardcoded fallback).
|
|
272
|
+
if (ctx.workspaceDir) {
|
|
273
|
+
const issue = validateWorkspaceDir(ctx.workspaceDir);
|
|
274
|
+
if (!issue) {
|
|
275
|
+
return {
|
|
276
|
+
ok: true,
|
|
277
|
+
workspaceDir: ctx.workspaceDir,
|
|
278
|
+
source: 'openclaw_context',
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Priority 3: OpenClaw API resolution
|
|
284
|
+
const apiResolved = tryResolveFromOpenClawApi(api, ctx.agentId);
|
|
285
|
+
if (apiResolved) {
|
|
286
|
+
return {
|
|
287
|
+
ok: true,
|
|
288
|
+
workspaceDir: apiResolved,
|
|
289
|
+
source: 'openclaw_api',
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Priority 4: pd_default (hardcoded fallback) — only when nothing else works
|
|
294
|
+
const resolveCanonical = options?.canonicalResolver ?? resolveCanonicalWorkspaceDir;
|
|
295
|
+
const canonical = resolveCanonical();
|
|
296
|
+
if (canonical && canonical.source === 'pd_default') {
|
|
297
|
+
return {
|
|
298
|
+
ok: true,
|
|
299
|
+
workspaceDir: canonical.workspaceDir,
|
|
300
|
+
source: 'pd_default',
|
|
301
|
+
consistencyWarning:
|
|
302
|
+
'Using hardcoded default workspace (~/.openclaw/workspace). ' +
|
|
303
|
+
'Set PD_WORKSPACE_DIR or create ~/.openclaw/principles-disciple.json for stable resolution.',
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return {
|
|
308
|
+
ok: false,
|
|
309
|
+
reason: 'workspace_dir_unresolvable',
|
|
310
|
+
nextAction:
|
|
311
|
+
'Set PD_WORKSPACE_DIR environment variable, create ~/.openclaw/principles-disciple.json ' +
|
|
312
|
+
'with a "workspace" field, or ensure OpenClaw provides workspaceDir in hook context.',
|
|
313
|
+
message:
|
|
314
|
+
`[PD:${source}] Cannot resolve workspace directory from any source. ` +
|
|
315
|
+
`PD explicit config (PD_WORKSPACE_DIR, principles-disciple.json) ` +
|
|
316
|
+
`and OpenClaw fallback (ctx.workspaceDir, api.resolveAgentWorkspaceDir, ~/.openclaw/workspace) all failed.`,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
86
320
|
/**
|
|
87
321
|
* Resolve workspace directory for tool hook execution (safe version).
|
|
88
322
|
* Returns undefined instead of throwing if resolution fails.
|
|
323
|
+
*
|
|
324
|
+
* PRI-259: Uses PD canonical config as primary source, OpenClaw as fallback.
|
|
89
325
|
*/
|
|
90
326
|
export function resolveToolHookWorkspaceDirSafe(
|
|
91
327
|
ctx: WorkspaceResolutionContext,
|
|
92
328
|
api: OpenClawPluginApi,
|
|
93
329
|
source: string,
|
|
330
|
+
options?: HookWorkspaceResolutionOptions,
|
|
94
331
|
): string | undefined {
|
|
95
|
-
|
|
332
|
+
const result = resolveHookWorkspaceDir(ctx, api, source, options);
|
|
333
|
+
|
|
334
|
+
if (!result.ok) {
|
|
335
|
+
api.logger.warn(result.message);
|
|
336
|
+
return undefined;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (result.consistencyWarning) {
|
|
340
|
+
api.logger.warn(`[PD:${source}] ${result.consistencyWarning}`);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return result.workspaceDir;
|
|
96
344
|
}
|
|
97
345
|
|
|
98
346
|
export class WorkspaceResolutionError extends Error {
|
|
@@ -315,4 +315,170 @@ describe('EventLog', () => {
|
|
|
315
315
|
expect(stats.evolution.rulehostAutoCorrectApplied).toBe(0);
|
|
316
316
|
});
|
|
317
317
|
});
|
|
318
|
+
|
|
319
|
+
describe('telemetry redaction', () => {
|
|
320
|
+
it('redacts lin_api_ token from rulehost_evaluated filePath', () => {
|
|
321
|
+
const sensitivePath = 'curl -s -H "Authorization: lin_api_TEST_REDACT_ME_1234567890ABCDEF" https://api.linear.app';
|
|
322
|
+
eventLog.recordRuleHostEvaluated({
|
|
323
|
+
toolName: 'bash',
|
|
324
|
+
filePath: sensitivePath,
|
|
325
|
+
matched: true,
|
|
326
|
+
decision: 'allow',
|
|
327
|
+
ruleId: 'r1',
|
|
328
|
+
});
|
|
329
|
+
eventLog.flush();
|
|
330
|
+
|
|
331
|
+
const eventsFile = path.join(tempDir, 'logs', 'events_' + new Date().toISOString().slice(0, 10) + '.jsonl');
|
|
332
|
+
const content = fs.readFileSync(eventsFile, 'utf-8');
|
|
333
|
+
expect(content).not.toContain('lin_api_TEST_REDACT_ME');
|
|
334
|
+
expect(content).toContain('[REDACTED]');
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('redacts Authorization header from tool_call data', () => {
|
|
338
|
+
eventLog.recordToolCall('s1', {
|
|
339
|
+
toolName: 'bash',
|
|
340
|
+
command: 'curl -H "Authorization: Bearer sk-TEST_REDACT_ME_1234567890" https://api.example.com',
|
|
341
|
+
error: undefined,
|
|
342
|
+
gfi: 0,
|
|
343
|
+
});
|
|
344
|
+
eventLog.flush();
|
|
345
|
+
|
|
346
|
+
const eventsFile = path.join(tempDir, 'logs', 'events_' + new Date().toISOString().slice(0, 10) + '.jsonl');
|
|
347
|
+
const content = fs.readFileSync(eventsFile, 'utf-8');
|
|
348
|
+
expect(content).not.toContain('sk-TEST_REDACT_ME_1234567890');
|
|
349
|
+
expect(content).not.toContain('Bearer sk-TEST_REDACT_ME');
|
|
350
|
+
expect(content).toContain('[REDACTED]');
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it('redacts ghp_ token from tool_call data', () => {
|
|
354
|
+
eventLog.recordToolCall('s1', {
|
|
355
|
+
toolName: 'bash',
|
|
356
|
+
command: 'ghp_TEST_REDACT_ME_1234567890ABCDEFGHIJKLMN',
|
|
357
|
+
error: undefined,
|
|
358
|
+
gfi: 0,
|
|
359
|
+
});
|
|
360
|
+
eventLog.flush();
|
|
361
|
+
|
|
362
|
+
const eventsFile = path.join(tempDir, 'logs', 'events_' + new Date().toISOString().slice(0, 10) + '.jsonl');
|
|
363
|
+
const content = fs.readFileSync(eventsFile, 'utf-8');
|
|
364
|
+
expect(content).not.toContain('ghp_TEST_REDACT_ME');
|
|
365
|
+
expect(content).toContain('[REDACTED]');
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it('redacts Bearer token in tool_call data', () => {
|
|
369
|
+
eventLog.recordToolCall('s1', {
|
|
370
|
+
toolName: 'bash',
|
|
371
|
+
command: 'curl -H "Authorization: Bearer TEST_REDACT_ME_TOKEN_1234567890"',
|
|
372
|
+
error: undefined,
|
|
373
|
+
gfi: 0,
|
|
374
|
+
});
|
|
375
|
+
eventLog.flush();
|
|
376
|
+
|
|
377
|
+
const eventsFile = path.join(tempDir, 'logs', 'events_' + new Date().toISOString().slice(0, 10) + '.jsonl');
|
|
378
|
+
const content = fs.readFileSync(eventsFile, 'utf-8');
|
|
379
|
+
expect(content).not.toContain('TEST_REDACT_ME_TOKEN');
|
|
380
|
+
expect(content).toContain('[REDACTED]');
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it('redacts env assignment in tool_call data', () => {
|
|
384
|
+
eventLog.recordToolCall('s1', {
|
|
385
|
+
toolName: 'bash',
|
|
386
|
+
command: 'LINEAR_API_KEY=lin_api_TEST_REDACT_ME_1234567890ABCDEF curl -s https://api.linear.app',
|
|
387
|
+
error: undefined,
|
|
388
|
+
gfi: 0,
|
|
389
|
+
});
|
|
390
|
+
eventLog.flush();
|
|
391
|
+
|
|
392
|
+
const eventsFile = path.join(tempDir, 'logs', 'events_' + new Date().toISOString().slice(0, 10) + '.jsonl');
|
|
393
|
+
const content = fs.readFileSync(eventsFile, 'utf-8');
|
|
394
|
+
expect(content).not.toContain('lin_api_TEST_REDACT_ME');
|
|
395
|
+
expect(content).toContain('[REDACTED]');
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it('preserves normal file path in rulehost_evaluated', () => {
|
|
399
|
+
const normalPath = 'src/app.ts';
|
|
400
|
+
eventLog.recordRuleHostEvaluated({
|
|
401
|
+
toolName: 'write',
|
|
402
|
+
filePath: normalPath,
|
|
403
|
+
matched: true,
|
|
404
|
+
decision: 'allow',
|
|
405
|
+
ruleId: 'r1',
|
|
406
|
+
});
|
|
407
|
+
eventLog.flush();
|
|
408
|
+
|
|
409
|
+
const eventsFile = path.join(tempDir, 'logs', 'events_' + new Date().toISOString().slice(0, 10) + '.jsonl');
|
|
410
|
+
const content = fs.readFileSync(eventsFile, 'utf-8');
|
|
411
|
+
expect(content).toContain(normalPath);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it('non-telemetry types are not affected', () => {
|
|
415
|
+
eventLog.recordPainSignal('s1', {
|
|
416
|
+
source: 'tool_failure',
|
|
417
|
+
score: 75,
|
|
418
|
+
reason: 'normal pain signal',
|
|
419
|
+
});
|
|
420
|
+
eventLog.flush();
|
|
421
|
+
|
|
422
|
+
const eventsFile = path.join(tempDir, 'logs', 'events_' + new Date().toISOString().slice(0, 10) + '.jsonl');
|
|
423
|
+
const content = fs.readFileSync(eventsFile, 'utf-8');
|
|
424
|
+
expect(content).toContain('normal pain signal');
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it('redaction failure returns masked payload, not raw secrets', () => {
|
|
428
|
+
// Contract check: the catch block in redactEventData must NOT return raw data.
|
|
429
|
+
// This is a static regression test for the ERR-002 fail-safe fix.
|
|
430
|
+
const eventLogSource = fs.readFileSync(
|
|
431
|
+
path.resolve(__dirname, '../../src/core/event-log.ts'),
|
|
432
|
+
'utf-8'
|
|
433
|
+
);
|
|
434
|
+
// Find the catch block lines (after '} catch')
|
|
435
|
+
const afterCatch = eventLogSource.match(/\}[\s\n]*catch[\s\n]*\([^)]*\)[\s\n]*\{([\s\S]*?)\}[\s\n]*(?:private|public|\n)/);
|
|
436
|
+
// If found, verify it doesn't contain 'return data'
|
|
437
|
+
if (afterCatch) {
|
|
438
|
+
expect(afterCatch[1]).not.toMatch(/return\s+data/);
|
|
439
|
+
}
|
|
440
|
+
// The catch block must produce a masked result with redactionStatus
|
|
441
|
+
expect(eventLogSource).toContain('redactionStatus');
|
|
442
|
+
expect(eventLogSource).toContain('redactionDataDropped');
|
|
443
|
+
expect(eventLogSource).toContain('redactionReason');
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it('verifies that EventLog persistence path does not store raw secret and stores masked fallback on redaction failure', () => {
|
|
447
|
+
// 1. Success case: log a sensitive command
|
|
448
|
+
eventLog.recordToolCall('s1', {
|
|
449
|
+
toolName: 'bash',
|
|
450
|
+
command: 'curl -H "Authorization: Bearer sk-TEST_REDACT_ME_999" https://api.openai.com',
|
|
451
|
+
error: undefined,
|
|
452
|
+
gfi: 0,
|
|
453
|
+
});
|
|
454
|
+
eventLog.flush();
|
|
455
|
+
|
|
456
|
+
const todayStr = new Date().toISOString().slice(0, 10);
|
|
457
|
+
const eventsFile = path.join(tempDir, 'logs', `events_${todayStr}.jsonl`);
|
|
458
|
+
let content = fs.readFileSync(eventsFile, 'utf-8');
|
|
459
|
+
expect(content).not.toContain('sk-TEST_REDACT_ME_999');
|
|
460
|
+
expect(content).toContain('[REDACTED]');
|
|
461
|
+
|
|
462
|
+
// 2. Redaction failure case: use a throwing getter to trigger catch block
|
|
463
|
+
const badData = {
|
|
464
|
+
toolName: 'bash',
|
|
465
|
+
get command(): string {
|
|
466
|
+
throw new Error('Simulated getter crash');
|
|
467
|
+
},
|
|
468
|
+
error: undefined,
|
|
469
|
+
gfi: 0,
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
eventLog.recordToolCall('s1', badData as any);
|
|
473
|
+
eventLog.flush();
|
|
474
|
+
|
|
475
|
+
content = fs.readFileSync(eventsFile, 'utf-8');
|
|
476
|
+
// The raw data must not be written, and instead the failure marker is present
|
|
477
|
+
expect(content).toContain('"redactionFailure":true');
|
|
478
|
+
expect(content).toContain('"redactionStatus":"failed"');
|
|
479
|
+
expect(content).toContain('"redaction.status":"failed"');
|
|
480
|
+
expect(content).toContain('Simulated getter crash');
|
|
481
|
+
});
|
|
482
|
+
});
|
|
318
483
|
});
|
|
484
|
+
|
|
@@ -7,36 +7,44 @@ const INDEX_TS = fs.readFileSync(
|
|
|
7
7
|
'utf-8',
|
|
8
8
|
);
|
|
9
9
|
|
|
10
|
+
const WORKSPACE_RESOLVER_TS = fs.readFileSync(
|
|
11
|
+
path.resolve(__dirname, '../src/utils/workspace-resolver.ts'),
|
|
12
|
+
'utf-8',
|
|
13
|
+
);
|
|
14
|
+
|
|
10
15
|
describe('Hook workspace resolution NextAction contract', () => {
|
|
11
|
-
|
|
12
|
-
/
|
|
13
|
-
|
|
14
|
-
];
|
|
15
|
-
|
|
16
|
-
it('does not claim PD_WORKSPACE_DIR env var as recovery in NextAction', () => {
|
|
17
|
-
const matches = INDEX_TS.match(/NextAction:[^`]*PD_WORKSPACE_DIR/g);
|
|
18
|
-
expect(matches).toBeNull();
|
|
16
|
+
it('no stale HOOK_WORKSPACE_RESOLUTION_NEXT_ACTION constant remains', () => {
|
|
17
|
+
const constantMatch = INDEX_TS.match(/HOOK_WORKSPACE_RESOLUTION_NEXT_ACTION/);
|
|
18
|
+
expect(constantMatch).toBeNull();
|
|
19
19
|
});
|
|
20
20
|
|
|
21
|
-
it('
|
|
22
|
-
const
|
|
23
|
-
|
|
21
|
+
it('resolveHookWorkspaceDir failure result includes PD canonical config in nextAction', () => {
|
|
22
|
+
const nextActionMatch = WORKSPACE_RESOLVER_TS.match(
|
|
23
|
+
/nextAction:\s*'([^']+)'/s,
|
|
24
|
+
);
|
|
25
|
+
expect(nextActionMatch).not.toBeNull();
|
|
26
|
+
const nextAction = nextActionMatch![1];
|
|
27
|
+
expect(nextAction).toContain('PD_WORKSPACE_DIR');
|
|
28
|
+
expect(nextAction).toContain('principles-disciple.json');
|
|
24
29
|
});
|
|
25
30
|
|
|
26
|
-
it('all hook failure
|
|
27
|
-
const
|
|
28
|
-
expect(
|
|
29
|
-
expect(
|
|
31
|
+
it('all hook failure paths use resolveHookWorkspaceDir with structured nextAction', () => {
|
|
32
|
+
const hookUsages = INDEX_TS.match(/resolveHookWorkspaceDir\(/g);
|
|
33
|
+
expect(hookUsages).not.toBeNull();
|
|
34
|
+
expect(hookUsages!.length).toBeGreaterThanOrEqual(6);
|
|
35
|
+
|
|
36
|
+
const wsResultOkChecks = INDEX_TS.match(/!wsResult\.ok/g);
|
|
37
|
+
expect(wsResultOkChecks).not.toBeNull();
|
|
38
|
+
expect(wsResultOkChecks!.length).toBeGreaterThanOrEqual(6);
|
|
39
|
+
|
|
40
|
+
const nextActionRefs = INDEX_TS.match(/wsResult\.nextAction/g);
|
|
41
|
+
expect(nextActionRefs).not.toBeNull();
|
|
42
|
+
expect(nextActionRefs!.length).toBeGreaterThanOrEqual(6);
|
|
30
43
|
});
|
|
31
44
|
|
|
32
|
-
it('
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
);
|
|
36
|
-
expect(constantMatch).not.toBeNull();
|
|
37
|
-
const constantValue = constantMatch![1];
|
|
38
|
-
for (const pattern of FORBIDDEN_NEXT_ACTION_PATTERNS) {
|
|
39
|
-
expect(pattern.test(constantValue)).toBe(false);
|
|
40
|
-
}
|
|
45
|
+
it('resolveHookWorkspaceDir failure result has reason and nextAction fields', () => {
|
|
46
|
+
expect(WORKSPACE_RESOLVER_TS).toContain("reason: 'workspace_dir_unresolvable'");
|
|
47
|
+
expect(WORKSPACE_RESOLVER_TS).toContain('nextAction:');
|
|
48
|
+
expect(WORKSPACE_RESOLVER_TS).toContain('PD_WORKSPACE_DIR');
|
|
41
49
|
});
|
|
42
50
|
});
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi, afterEach } from 'vitest';
|
|
2
|
+
import * as os from 'os';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import * as fs from 'fs';
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
resolveCanonicalWorkspaceDir,
|
|
8
|
+
resolveHookWorkspaceDir,
|
|
9
|
+
resolveToolHookWorkspaceDirSafe,
|
|
10
|
+
} from '../../src/utils/workspace-resolver.js';
|
|
11
|
+
import type { CanonicalWorkspaceResult, HookWorkspaceResolutionResult } from '../../src/utils/workspace-resolver.js';
|
|
12
|
+
|
|
13
|
+
const homeDir = os.homedir();
|
|
14
|
+
const validWorkspace = path.join(os.tmpdir(), 'pd-test-workspace-hook-resolver');
|
|
15
|
+
|
|
16
|
+
function ensureDir(dir: string): void {
|
|
17
|
+
if (!fs.existsSync(dir)) {
|
|
18
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const noCanonical: () => null = () => null;
|
|
23
|
+
|
|
24
|
+
describe('resolveCanonicalWorkspaceDir', () => {
|
|
25
|
+
const originalEnv = { ...process.env };
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
process.env = { ...originalEnv };
|
|
29
|
+
delete process.env.PD_WORKSPACE_DIR;
|
|
30
|
+
delete process.env.OPENCLAW_WORKSPACE;
|
|
31
|
+
ensureDir(validWorkspace);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
process.env = { ...originalEnv };
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('resolves from PD_WORKSPACE_DIR env var with highest priority', () => {
|
|
39
|
+
process.env.PD_WORKSPACE_DIR = validWorkspace;
|
|
40
|
+
process.env.OPENCLAW_WORKSPACE = '/some/other/path';
|
|
41
|
+
const result = resolveCanonicalWorkspaceDir();
|
|
42
|
+
expect(result).not.toBeNull();
|
|
43
|
+
expect(result!.source).toBe('pd_env');
|
|
44
|
+
expect(result!.workspaceDir).toBe(path.resolve(validWorkspace));
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('resolves from OPENCLAW_WORKSPACE env var when PD_WORKSPACE_DIR is not set', () => {
|
|
48
|
+
process.env.OPENCLAW_WORKSPACE = validWorkspace;
|
|
49
|
+
const result = resolveCanonicalWorkspaceDir();
|
|
50
|
+
expect(result).not.toBeNull();
|
|
51
|
+
expect(result!.source).toBe('openclaw_env');
|
|
52
|
+
expect(result!.workspaceDir).toBe(path.resolve(validWorkspace));
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('prefers PD_WORKSPACE_DIR over OPENCLAW_WORKSPACE', () => {
|
|
56
|
+
const altWorkspace = path.join(os.tmpdir(), 'pd-test-workspace-alt');
|
|
57
|
+
ensureDir(altWorkspace);
|
|
58
|
+
process.env.PD_WORKSPACE_DIR = validWorkspace;
|
|
59
|
+
process.env.OPENCLAW_WORKSPACE = altWorkspace;
|
|
60
|
+
const result = resolveCanonicalWorkspaceDir();
|
|
61
|
+
expect(result).not.toBeNull();
|
|
62
|
+
expect(result!.source).toBe('pd_env');
|
|
63
|
+
expect(result!.workspaceDir).toBe(path.resolve(validWorkspace));
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('rejects home directory from PD_WORKSPACE_DIR', () => {
|
|
67
|
+
process.env.PD_WORKSPACE_DIR = homeDir;
|
|
68
|
+
const result = resolveCanonicalWorkspaceDir();
|
|
69
|
+
if (result?.source === 'pd_env') {
|
|
70
|
+
expect.fail('Should not resolve home directory from PD_WORKSPACE_DIR');
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('rejects empty string from PD_WORKSPACE_DIR', () => {
|
|
75
|
+
process.env.PD_WORKSPACE_DIR = '';
|
|
76
|
+
const result = resolveCanonicalWorkspaceDir();
|
|
77
|
+
if (result?.source === 'pd_env') {
|
|
78
|
+
expect.fail('Should not resolve empty PD_WORKSPACE_DIR');
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('always returns a result when PD_WORKSPACE_DIR points to a valid dir', () => {
|
|
83
|
+
process.env.PD_WORKSPACE_DIR = validWorkspace;
|
|
84
|
+
const result = resolveCanonicalWorkspaceDir();
|
|
85
|
+
expect(result).not.toBeNull();
|
|
86
|
+
expect(result!.workspaceDir).toBe(path.resolve(validWorkspace));
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('resolveHookWorkspaceDir — PD canonical primary', () => {
|
|
91
|
+
const originalEnv = { ...process.env };
|
|
92
|
+
const logger = {
|
|
93
|
+
error: vi.fn(),
|
|
94
|
+
warn: vi.fn(),
|
|
95
|
+
info: vi.fn(),
|
|
96
|
+
debug: vi.fn(),
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const api = {
|
|
100
|
+
runtime: {
|
|
101
|
+
agent: {
|
|
102
|
+
resolveAgentWorkspaceDir: vi.fn(),
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
config: {},
|
|
106
|
+
logger,
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
beforeEach(() => {
|
|
110
|
+
process.env = { ...originalEnv };
|
|
111
|
+
delete process.env.PD_WORKSPACE_DIR;
|
|
112
|
+
delete process.env.OPENCLAW_WORKSPACE;
|
|
113
|
+
vi.clearAllMocks();
|
|
114
|
+
ensureDir(validWorkspace);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
afterEach(() => {
|
|
118
|
+
process.env = { ...originalEnv };
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('uses PD_WORKSPACE_DIR as primary source regardless of OpenClaw context', () => {
|
|
122
|
+
process.env.PD_WORKSPACE_DIR = validWorkspace;
|
|
123
|
+
const result = resolveHookWorkspaceDir(
|
|
124
|
+
{ workspaceDir: '/some/openclaw/path', agentId: 'main' },
|
|
125
|
+
api as any,
|
|
126
|
+
'test',
|
|
127
|
+
);
|
|
128
|
+
expect(result.ok).toBe(true);
|
|
129
|
+
if (result.ok) {
|
|
130
|
+
expect(result.source).toBe('pd_env');
|
|
131
|
+
expect(result.workspaceDir).toBe(path.resolve(validWorkspace));
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('emits consistency warning when PD canonical differs from OpenClaw context', () => {
|
|
136
|
+
const altWorkspace = path.join(os.tmpdir(), 'pd-test-workspace-alt-2');
|
|
137
|
+
ensureDir(altWorkspace);
|
|
138
|
+
process.env.PD_WORKSPACE_DIR = validWorkspace;
|
|
139
|
+
const result = resolveHookWorkspaceDir(
|
|
140
|
+
{ workspaceDir: altWorkspace },
|
|
141
|
+
api as any,
|
|
142
|
+
'test',
|
|
143
|
+
);
|
|
144
|
+
expect(result.ok).toBe(true);
|
|
145
|
+
if (result.ok) {
|
|
146
|
+
expect(result.source).toBe('pd_env');
|
|
147
|
+
expect(result.workspaceDir).toBe(path.resolve(validWorkspace));
|
|
148
|
+
expect(result.consistencyWarning).toContain('differs from OpenClaw context');
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('does not emit consistency warning when PD canonical matches OpenClaw context', () => {
|
|
153
|
+
process.env.PD_WORKSPACE_DIR = validWorkspace;
|
|
154
|
+
const result = resolveHookWorkspaceDir(
|
|
155
|
+
{ workspaceDir: validWorkspace },
|
|
156
|
+
api as any,
|
|
157
|
+
'test',
|
|
158
|
+
);
|
|
159
|
+
expect(result.ok).toBe(true);
|
|
160
|
+
if (result.ok) {
|
|
161
|
+
expect(result.consistencyWarning).toBeUndefined();
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('falls back to OpenClaw context when no PD explicit config exists', () => {
|
|
166
|
+
const result = resolveHookWorkspaceDir(
|
|
167
|
+
{ workspaceDir: validWorkspace },
|
|
168
|
+
api as any,
|
|
169
|
+
'test',
|
|
170
|
+
{ canonicalResolver: noCanonical, explicitPdResolver: noCanonical },
|
|
171
|
+
);
|
|
172
|
+
expect(result.ok).toBe(true);
|
|
173
|
+
if (result.ok) {
|
|
174
|
+
expect(result.source).toBe('openclaw_context');
|
|
175
|
+
expect(result.workspaceDir).toBe(validWorkspace);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('falls back to OpenClaw API when context is also missing', () => {
|
|
180
|
+
api.runtime.agent.resolveAgentWorkspaceDir.mockReturnValue(validWorkspace);
|
|
181
|
+
const result = resolveHookWorkspaceDir(
|
|
182
|
+
{},
|
|
183
|
+
api as any,
|
|
184
|
+
'test',
|
|
185
|
+
{ canonicalResolver: noCanonical, explicitPdResolver: noCanonical },
|
|
186
|
+
);
|
|
187
|
+
expect(result.ok).toBe(true);
|
|
188
|
+
if (result.ok) {
|
|
189
|
+
expect(result.source).toBe('openclaw_api');
|
|
190
|
+
expect(result.workspaceDir).toBe(validWorkspace);
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('returns structured failure with reason and nextAction when all sources fail', () => {
|
|
195
|
+
api.runtime.agent.resolveAgentWorkspaceDir.mockReturnValue(homeDir);
|
|
196
|
+
const result = resolveHookWorkspaceDir(
|
|
197
|
+
{},
|
|
198
|
+
api as any,
|
|
199
|
+
'test_hook',
|
|
200
|
+
{ canonicalResolver: noCanonical, explicitPdResolver: noCanonical },
|
|
201
|
+
);
|
|
202
|
+
expect(result.ok).toBe(false);
|
|
203
|
+
if (!result.ok) {
|
|
204
|
+
expect(result.reason).toBe('workspace_dir_unresolvable');
|
|
205
|
+
expect(result.nextAction).toContain('PD_WORKSPACE_DIR');
|
|
206
|
+
expect(result.nextAction).toContain('principles-disciple.json');
|
|
207
|
+
expect(result.message).toContain('test_hook');
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('rejects home directory from OpenClaw context and falls back to API', () => {
|
|
212
|
+
api.runtime.agent.resolveAgentWorkspaceDir.mockReturnValue(validWorkspace);
|
|
213
|
+
const result = resolveHookWorkspaceDir(
|
|
214
|
+
{ workspaceDir: homeDir, agentId: 'main' },
|
|
215
|
+
api as any,
|
|
216
|
+
'test',
|
|
217
|
+
{ canonicalResolver: noCanonical, explicitPdResolver: noCanonical },
|
|
218
|
+
);
|
|
219
|
+
expect(result.ok).toBe(true);
|
|
220
|
+
if (result.ok) {
|
|
221
|
+
expect(result.source).toBe('openclaw_api');
|
|
222
|
+
expect(result.workspaceDir).toBe(validWorkspace);
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('ctx.workspaceDir takes priority over pd_default when no explicit PD source exists', () => {
|
|
227
|
+
// canonicalResolver returns pd_default, but ctx.workspaceDir is a real workspace
|
|
228
|
+
const pdDefaultResolver = (): CanonicalWorkspaceResult => ({
|
|
229
|
+
workspaceDir: path.join(homeDir, '.openclaw', 'workspace'),
|
|
230
|
+
source: 'pd_default',
|
|
231
|
+
});
|
|
232
|
+
const result = resolveHookWorkspaceDir(
|
|
233
|
+
{ workspaceDir: validWorkspace },
|
|
234
|
+
api as any,
|
|
235
|
+
'test',
|
|
236
|
+
{ canonicalResolver: pdDefaultResolver, explicitPdResolver: noCanonical },
|
|
237
|
+
);
|
|
238
|
+
expect(result.ok).toBe(true);
|
|
239
|
+
if (result.ok) {
|
|
240
|
+
expect(result.source).toBe('openclaw_context');
|
|
241
|
+
expect(result.workspaceDir).toBe(validWorkspace);
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('returns failure when API throws and no other source works', () => {
|
|
246
|
+
api.runtime.agent.resolveAgentWorkspaceDir.mockImplementation(() => {
|
|
247
|
+
throw new Error('API unavailable');
|
|
248
|
+
});
|
|
249
|
+
const result = resolveHookWorkspaceDir(
|
|
250
|
+
{},
|
|
251
|
+
api as any,
|
|
252
|
+
'test_hook',
|
|
253
|
+
{ canonicalResolver: noCanonical, explicitPdResolver: noCanonical },
|
|
254
|
+
);
|
|
255
|
+
expect(result.ok).toBe(false);
|
|
256
|
+
if (!result.ok) {
|
|
257
|
+
expect(result.reason).toBe('workspace_dir_unresolvable');
|
|
258
|
+
expect(result.nextAction).toContain('PD_WORKSPACE_DIR');
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
describe('resolveToolHookWorkspaceDirSafe (backward compat)', () => {
|
|
264
|
+
const originalEnv = { ...process.env };
|
|
265
|
+
const logger = {
|
|
266
|
+
error: vi.fn(),
|
|
267
|
+
warn: vi.fn(),
|
|
268
|
+
info: vi.fn(),
|
|
269
|
+
debug: vi.fn(),
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const api = {
|
|
273
|
+
runtime: {
|
|
274
|
+
agent: {
|
|
275
|
+
resolveAgentWorkspaceDir: vi.fn(),
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
config: {},
|
|
279
|
+
logger,
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
beforeEach(() => {
|
|
283
|
+
process.env = { ...originalEnv };
|
|
284
|
+
delete process.env.PD_WORKSPACE_DIR;
|
|
285
|
+
delete process.env.OPENCLAW_WORKSPACE;
|
|
286
|
+
vi.clearAllMocks();
|
|
287
|
+
ensureDir(validWorkspace);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
afterEach(() => {
|
|
291
|
+
process.env = { ...originalEnv };
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('returns string when PD_WORKSPACE_DIR resolves', () => {
|
|
295
|
+
process.env.PD_WORKSPACE_DIR = validWorkspace;
|
|
296
|
+
const result = resolveToolHookWorkspaceDirSafe({}, api as any, 'test');
|
|
297
|
+
expect(result).toBe(path.resolve(validWorkspace));
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('logs consistency warning when PD canonical differs from context', () => {
|
|
301
|
+
const altWorkspace = path.join(os.tmpdir(), 'pd-test-workspace-alt-3');
|
|
302
|
+
ensureDir(altWorkspace);
|
|
303
|
+
process.env.PD_WORKSPACE_DIR = validWorkspace;
|
|
304
|
+
const result = resolveToolHookWorkspaceDirSafe(
|
|
305
|
+
{ workspaceDir: altWorkspace },
|
|
306
|
+
api as any,
|
|
307
|
+
'test',
|
|
308
|
+
);
|
|
309
|
+
expect(result).toBe(path.resolve(validWorkspace));
|
|
310
|
+
expect(logger.warn).toHaveBeenCalledWith(
|
|
311
|
+
expect.stringContaining('differs from OpenClaw context'),
|
|
312
|
+
);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('returns pd_default when only default fallback is available', () => {
|
|
316
|
+
// When no explicit PD source, no ctx.workspaceDir, and no API resolution,
|
|
317
|
+
// resolveToolHookWorkspaceDirSafe falls back to pd_default
|
|
318
|
+
api.runtime.agent.resolveAgentWorkspaceDir.mockReturnValue(homeDir);
|
|
319
|
+
const result = resolveToolHookWorkspaceDirSafe(
|
|
320
|
+
{},
|
|
321
|
+
api as any,
|
|
322
|
+
'test',
|
|
323
|
+
);
|
|
324
|
+
// pd_default (~/.openclaw/workspace) is used as last resort
|
|
325
|
+
expect(result).toBeDefined();
|
|
326
|
+
expect(result).toContain('.openclaw');
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('returns undefined and logs when all sources including pd_default fail', () => {
|
|
330
|
+
api.runtime.agent.resolveAgentWorkspaceDir.mockImplementation(() => {
|
|
331
|
+
throw new Error('no workspace');
|
|
332
|
+
});
|
|
333
|
+
const result = resolveToolHookWorkspaceDirSafe(
|
|
334
|
+
{},
|
|
335
|
+
api as any,
|
|
336
|
+
'test_hook',
|
|
337
|
+
{ canonicalResolver: noCanonical, explicitPdResolver: noCanonical },
|
|
338
|
+
);
|
|
339
|
+
expect(result).toBeUndefined();
|
|
340
|
+
expect(logger.warn).toHaveBeenCalled();
|
|
341
|
+
const warnCalls = logger.warn.mock.calls.map((c: unknown[]) => String(c[0]));
|
|
342
|
+
const fullWarn = warnCalls.join('\n');
|
|
343
|
+
expect(fullWarn).toContain('Cannot resolve workspace directory');
|
|
344
|
+
expect(fullWarn).toContain('PD_WORKSPACE_DIR');
|
|
345
|
+
expect(fullWarn).toContain('principles-disciple.json');
|
|
346
|
+
});
|
|
347
|
+
});
|