principles-disciple 1.84.0 → 1.86.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/tests/core/event-log.test.ts +166 -0
- package/tests/service/correction-observer-service.test.ts +49 -1
- package/tests/utils/hook-workspace-resolver.test.ts +177 -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;
|
|
@@ -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
|
+
|
|
@@ -65,7 +65,7 @@ vi.mock('@principles/core/runtime-v2', () => {
|
|
|
65
65
|
};
|
|
66
66
|
});
|
|
67
67
|
|
|
68
|
-
import { CorrectionObserverService, runCorrectionObserverCycle } from '../../src/service/correction-observer-service.js';
|
|
68
|
+
import { CorrectionObserverService, runCorrectionObserverCycle, resolveCorrectionObserver } from '../../src/service/correction-observer-service.js';
|
|
69
69
|
import { safeRmDir } from '../test-utils.js';
|
|
70
70
|
|
|
71
71
|
describe('CorrectionObserverService — Independent Service (PRI-293)', () => {
|
|
@@ -329,3 +329,51 @@ describe('runCorrectionObserverCycle — Independent Execution', () => {
|
|
|
329
329
|
}
|
|
330
330
|
});
|
|
331
331
|
});
|
|
332
|
+
|
|
333
|
+
describe('resolveCorrectionObserver — Configuration Resolution', () => {
|
|
334
|
+
beforeEach(() => {
|
|
335
|
+
vi.clearAllMocks();
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it('returns observer when API key env is set with mocked policy', async () => {
|
|
339
|
+
const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-corr-resolve-'));
|
|
340
|
+
const stateDir = path.join(workspaceDir, '.state');
|
|
341
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
342
|
+
|
|
343
|
+
process.env.ANTHROPIC_API_KEY = 'test-key';
|
|
344
|
+
|
|
345
|
+
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
|
|
346
|
+
|
|
347
|
+
try {
|
|
348
|
+
const wctx = WorkspaceContext.fromHookContext({ workspaceDir });
|
|
349
|
+
const result = resolveCorrectionObserver(wctx, logger as any);
|
|
350
|
+
|
|
351
|
+
// With mocked WorkflowFunnelLoader returning valid policy, should return observer
|
|
352
|
+
expect(result).not.toBeNull();
|
|
353
|
+
} finally {
|
|
354
|
+
delete process.env.ANTHROPIC_API_KEY;
|
|
355
|
+
safeRmDir(workspaceDir);
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it('returns observer when workflows.yaml provides valid policy', async () => {
|
|
360
|
+
const workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-corr-policy-'));
|
|
361
|
+
const stateDir = path.join(workspaceDir, '.state');
|
|
362
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
363
|
+
|
|
364
|
+
process.env.ANTHROPIC_API_KEY = 'test-key';
|
|
365
|
+
|
|
366
|
+
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
|
|
367
|
+
|
|
368
|
+
try {
|
|
369
|
+
const wctx = WorkspaceContext.fromHookContext({ workspaceDir });
|
|
370
|
+
const result = resolveCorrectionObserver(wctx, logger as any);
|
|
371
|
+
|
|
372
|
+
// With mocked WorkflowFunnelLoader returning valid policy, should return observer
|
|
373
|
+
expect(result).not.toBeNull();
|
|
374
|
+
} finally {
|
|
375
|
+
delete process.env.ANTHROPIC_API_KEY;
|
|
376
|
+
safeRmDir(workspaceDir);
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
});
|
|
@@ -7,6 +7,10 @@ import {
|
|
|
7
7
|
resolveCanonicalWorkspaceDir,
|
|
8
8
|
resolveHookWorkspaceDir,
|
|
9
9
|
resolveToolHookWorkspaceDirSafe,
|
|
10
|
+
resolveCommandWorkspaceDir,
|
|
11
|
+
resolvePluginCommandWorkspaceDir,
|
|
12
|
+
resolveWorkspaceDirForRuntimeV2,
|
|
13
|
+
WorkspaceResolutionError,
|
|
10
14
|
} from '../../src/utils/workspace-resolver.js';
|
|
11
15
|
import type { CanonicalWorkspaceResult, HookWorkspaceResolutionResult } from '../../src/utils/workspace-resolver.js';
|
|
12
16
|
|
|
@@ -345,3 +349,176 @@ describe('resolveToolHookWorkspaceDirSafe (backward compat)', () => {
|
|
|
345
349
|
expect(fullWarn).toContain('principles-disciple.json');
|
|
346
350
|
});
|
|
347
351
|
});
|
|
352
|
+
|
|
353
|
+
describe('resolveCommandWorkspaceDir — Command Resolution', () => {
|
|
354
|
+
const originalEnv = { ...process.env };
|
|
355
|
+
const logger = {
|
|
356
|
+
error: vi.fn(),
|
|
357
|
+
warn: vi.fn(),
|
|
358
|
+
info: vi.fn(),
|
|
359
|
+
debug: vi.fn(),
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
const api = {
|
|
363
|
+
runtime: {
|
|
364
|
+
agent: {
|
|
365
|
+
resolveAgentWorkspaceDir: vi.fn(),
|
|
366
|
+
},
|
|
367
|
+
},
|
|
368
|
+
config: {},
|
|
369
|
+
logger,
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
beforeEach(() => {
|
|
373
|
+
process.env = { ...originalEnv };
|
|
374
|
+
delete process.env.PD_WORKSPACE_DIR;
|
|
375
|
+
delete process.env.OPENCLAW_WORKSPACE;
|
|
376
|
+
vi.clearAllMocks();
|
|
377
|
+
ensureDir(validWorkspace);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
afterEach(() => {
|
|
381
|
+
process.env = { ...originalEnv };
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it('returns ctx.workspaceDir when valid', () => {
|
|
385
|
+
const result = resolveCommandWorkspaceDir(api as any, { workspaceDir: validWorkspace });
|
|
386
|
+
expect(result).toBe(validWorkspace);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it('throws when ctx.workspaceDir is home directory', () => {
|
|
390
|
+
expect(() => resolveCommandWorkspaceDir(api as any, { workspaceDir: homeDir }))
|
|
391
|
+
.toThrow(/is invalid/);
|
|
392
|
+
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('is invalid'));
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it('throws when ctx.workspaceDir is empty string', () => {
|
|
396
|
+
expect(() => resolveCommandWorkspaceDir(api as any, { workspaceDir: '' }))
|
|
397
|
+
.toThrow(/Cannot resolve workspace directory/);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it('falls back to API resolution when ctx.workspaceDir is undefined', () => {
|
|
401
|
+
api.runtime.agent.resolveAgentWorkspaceDir.mockReturnValue(validWorkspace);
|
|
402
|
+
process.env.OPENCLAW_WORKSPACE = validWorkspace;
|
|
403
|
+
const result = resolveCommandWorkspaceDir(api as any, {});
|
|
404
|
+
expect(result).toBe(path.resolve(validWorkspace));
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it('falls back to PathResolver default when API throws', () => {
|
|
408
|
+
api.runtime.agent.resolveAgentWorkspaceDir.mockImplementation(() => {
|
|
409
|
+
throw new Error('API unavailable');
|
|
410
|
+
});
|
|
411
|
+
// PathResolver provides default ~/.openclaw/workspace fallback
|
|
412
|
+
const result = resolveCommandWorkspaceDir(api as any, {});
|
|
413
|
+
expect(result).toBeDefined();
|
|
414
|
+
expect(result).toContain('.openclaw');
|
|
415
|
+
});
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
describe('resolvePluginCommandWorkspaceDir — Plugin Command Resolution', () => {
|
|
419
|
+
beforeEach(() => {
|
|
420
|
+
vi.clearAllMocks();
|
|
421
|
+
ensureDir(validWorkspace);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it('returns ctx.workspaceDir when valid', () => {
|
|
425
|
+
const ctx = { workspaceDir: validWorkspace, config: {} };
|
|
426
|
+
const result = resolvePluginCommandWorkspaceDir(ctx as any, 'test-source');
|
|
427
|
+
expect(result).toBe(validWorkspace);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it('throws when ctx.workspaceDir is home directory', () => {
|
|
431
|
+
const ctx = { workspaceDir: homeDir, config: {} };
|
|
432
|
+
expect(() => resolvePluginCommandWorkspaceDir(ctx as any, 'test-source'))
|
|
433
|
+
.toThrow(/is invalid/);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it('falls back to ctx.config.workspaceDir when ctx.workspaceDir is undefined', () => {
|
|
437
|
+
const ctx = { workspaceDir: undefined, config: { workspaceDir: validWorkspace } };
|
|
438
|
+
const result = resolvePluginCommandWorkspaceDir(ctx as any, 'test-source');
|
|
439
|
+
expect(result).toBe(validWorkspace);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it('throws when both ctx.workspaceDir and ctx.config.workspaceDir are invalid', () => {
|
|
443
|
+
const ctx = { workspaceDir: homeDir, config: { workspaceDir: homeDir } };
|
|
444
|
+
expect(() => resolvePluginCommandWorkspaceDir(ctx as any, 'test-source'))
|
|
445
|
+
.toThrow(/is invalid/);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it('throws critical error when no workspaceDir available', () => {
|
|
449
|
+
const ctx = { workspaceDir: undefined, config: {} };
|
|
450
|
+
expect(() => resolvePluginCommandWorkspaceDir(ctx as any, 'test-source'))
|
|
451
|
+
.toThrow(/CRITICAL: workspaceDir is not set/);
|
|
452
|
+
});
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
describe('resolveWorkspaceDirForRuntimeV2 — Runtime V2 Resolution', () => {
|
|
456
|
+
beforeEach(() => {
|
|
457
|
+
vi.clearAllMocks();
|
|
458
|
+
ensureDir(validWorkspace);
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it('returns normalized workspaceDir when valid', () => {
|
|
462
|
+
const result = resolveWorkspaceDirForRuntimeV2(
|
|
463
|
+
{ workspaceDir: validWorkspace },
|
|
464
|
+
undefined,
|
|
465
|
+
'runtime-v2-test',
|
|
466
|
+
);
|
|
467
|
+
expect(result).toBe(path.resolve(validWorkspace));
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it('throws WorkspaceResolutionError when workspaceDir is empty', () => {
|
|
471
|
+
expect(() => resolveWorkspaceDirForRuntimeV2({ workspaceDir: '' }, undefined, 'test'))
|
|
472
|
+
.toThrow(WorkspaceResolutionError);
|
|
473
|
+
|
|
474
|
+
try {
|
|
475
|
+
resolveWorkspaceDirForRuntimeV2({ workspaceDir: '' }, undefined, 'test');
|
|
476
|
+
} catch (e) {
|
|
477
|
+
expect((e as WorkspaceResolutionError).reason).toBe('workspace_dir_missing');
|
|
478
|
+
expect((e as WorkspaceResolutionError).nextAction).toContain('PD_WORKSPACE_DIR');
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it('throws WorkspaceResolutionError when workspaceDir is undefined', () => {
|
|
483
|
+
expect(() => resolveWorkspaceDirForRuntimeV2({}, undefined, 'test'))
|
|
484
|
+
.toThrow(WorkspaceResolutionError);
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it('throws WorkspaceResolutionError when workspaceDir is home directory', () => {
|
|
488
|
+
expect(() => resolveWorkspaceDirForRuntimeV2({ workspaceDir: homeDir }, undefined, 'test'))
|
|
489
|
+
.toThrow(WorkspaceResolutionError);
|
|
490
|
+
|
|
491
|
+
try {
|
|
492
|
+
resolveWorkspaceDirForRuntimeV2({ workspaceDir: homeDir }, undefined, 'test');
|
|
493
|
+
} catch (e) {
|
|
494
|
+
expect((e as WorkspaceResolutionError).reason).toBe('workspace_dir_invalid');
|
|
495
|
+
}
|
|
496
|
+
});
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
describe('WorkspaceResolutionError — Error Structure', () => {
|
|
500
|
+
it('has correct name and properties', () => {
|
|
501
|
+
const error = new WorkspaceResolutionError(
|
|
502
|
+
'Test message',
|
|
503
|
+
'test_reason',
|
|
504
|
+
'Test next action',
|
|
505
|
+
);
|
|
506
|
+
expect(error.name).toBe('WorkspaceResolutionError');
|
|
507
|
+
expect(error.message).toBe('Test message');
|
|
508
|
+
expect(error.reason).toBe('test_reason');
|
|
509
|
+
expect(error.nextAction).toBe('Test next action');
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
it('toJSON returns structured failure object', () => {
|
|
513
|
+
const error = new WorkspaceResolutionError(
|
|
514
|
+
'Test message',
|
|
515
|
+
'test_reason',
|
|
516
|
+
'Test next action',
|
|
517
|
+
);
|
|
518
|
+
const json = error.toJSON();
|
|
519
|
+
expect(json.ok).toBe(false);
|
|
520
|
+
expect(json.reason).toBe('test_reason');
|
|
521
|
+
expect(json.message).toBe('Test message');
|
|
522
|
+
expect(json.nextAction).toBe('Test next action');
|
|
523
|
+
});
|
|
524
|
+
});
|