principles-disciple 1.83.1 → 1.84.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
CHANGED
package/package.json
CHANGED
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 {
|
|
@@ -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
|
+
});
|