principles-disciple 1.86.0 → 1.88.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/pd-config-loader.ts +400 -0
- package/src/core/runtime-v2-prompt-activation-reader.ts +15 -63
- package/src/core/surface-guard.ts +62 -4
- package/src/index.ts +8 -56
- package/src/service/correction-observer-service.ts +62 -31
- package/tests/core/pd-config-loader.test.ts +407 -0
- package/tests/core/surface-guard.test.ts +142 -0
- package/tests/core-anti-growth.test.ts +1 -0
- package/tests/evolution-worker-quarantine.test.ts +83 -27
- package/tests/evolution-worker-slimming.test.ts +63 -5
- package/tests/hooks/runtime-v2-prompt-activation.test.ts +9 -3
- package/tests/integration/mvp-surface-registry-guard.test.ts +131 -1
- package/tests/service/correction-observer-service.test.ts +147 -21
- package/tests/service/evolution-worker.correction-observer.test.ts +1 -1
|
@@ -6,11 +6,18 @@ import {
|
|
|
6
6
|
guardService,
|
|
7
7
|
getSurfaceIdForHook,
|
|
8
8
|
getSurfaceIdForService,
|
|
9
|
+
__resetSurfaceGuardSkipLogStateForTests,
|
|
9
10
|
} from '../../src/core/surface-guard.js';
|
|
10
11
|
import { PLUGIN_SURFACE_REGISTRY } from '@principles/core/runtime-v2';
|
|
11
12
|
import type { OpenClawPluginService } from '../../src/openclaw-sdk.js';
|
|
12
13
|
|
|
13
14
|
describe('surface-guard', () => {
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
// Each test starts with a clean surface-guard skip log state so the
|
|
17
|
+
// first-fire assertions are deterministic (PRI-298).
|
|
18
|
+
__resetSurfaceGuardSkipLogStateForTests();
|
|
19
|
+
vi.clearAllMocks();
|
|
20
|
+
});
|
|
14
21
|
describe('getSurfaceIdForHook', () => {
|
|
15
22
|
it('generates correct surface id without label', () => {
|
|
16
23
|
expect(getSurfaceIdForHook('before_tool_call')).toBe('hook:before_tool_call');
|
|
@@ -144,6 +151,39 @@ describe('surface-guard', () => {
|
|
|
144
151
|
expect(mockHandler).not.toHaveBeenCalled();
|
|
145
152
|
});
|
|
146
153
|
|
|
154
|
+
it('does not log at guardHook construction time (PRI-298 / chatgpt P2)', () => {
|
|
155
|
+
// Registering a guard for a quiet hook must not emit the SKIP line on
|
|
156
|
+
// its own; the log fires only when the returned no-op is actually
|
|
157
|
+
// invoked. Plugin startup that registers a dozen quiet hooks would
|
|
158
|
+
// otherwise log a dozen SKIP lines before any real traffic.
|
|
159
|
+
const mockLogger = { info: vi.fn(), debug: vi.fn() };
|
|
160
|
+
guardHook('hook:after_tool_call.trajectory', mockLogger, vi.fn());
|
|
161
|
+
expect(mockLogger.info).not.toHaveBeenCalled();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('logger undefined on first fire does not consume the one-shot slot (PRI-298 / coderabbit Major)', () => {
|
|
165
|
+
// First call has no logger — the no-op still suppresses the handler,
|
|
166
|
+
// and the once-only slot is preserved for a later call that does
|
|
167
|
+
// have a logger.
|
|
168
|
+
const handler1 = guardHook('hook:after_tool_call.trajectory', undefined, vi.fn());
|
|
169
|
+
handler1({}, {});
|
|
170
|
+
|
|
171
|
+
const mockLogger = { info: vi.fn(), debug: vi.fn() };
|
|
172
|
+
const handler2 = guardHook('hook:after_tool_call.trajectory', mockLogger, vi.fn());
|
|
173
|
+
handler2({}, {});
|
|
174
|
+
|
|
175
|
+
// Only the second call (which had a logger) should have emitted a log.
|
|
176
|
+
expect(mockLogger.info).toHaveBeenCalledTimes(1);
|
|
177
|
+
expect(mockLogger.info).toHaveBeenCalledWith(
|
|
178
|
+
expect.stringContaining('[PD:surface-guard] SKIP hook:after_tool_call.trajectory'),
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
// A third call should now be silent (slot consumed by the second call).
|
|
182
|
+
const handler3 = guardHook('hook:after_tool_call.trajectory', mockLogger, vi.fn());
|
|
183
|
+
handler3({}, {});
|
|
184
|
+
expect(mockLogger.info).toHaveBeenCalledTimes(1);
|
|
185
|
+
});
|
|
186
|
+
|
|
147
187
|
it('does not log for enabled surface', () => {
|
|
148
188
|
const mockLogger = { info: vi.fn(), debug: vi.fn() };
|
|
149
189
|
const mockHandler = vi.fn();
|
|
@@ -160,6 +200,45 @@ describe('surface-guard', () => {
|
|
|
160
200
|
expect.stringContaining('not found in registry'),
|
|
161
201
|
);
|
|
162
202
|
});
|
|
203
|
+
|
|
204
|
+
it('logs once on first fire and stays silent on subsequent fires (PRI-298 rate-limit)', () => {
|
|
205
|
+
const mockLogger = { info: vi.fn(), debug: vi.fn() };
|
|
206
|
+
const mockHandler = vi.fn();
|
|
207
|
+
const guarded = guardHook('hook:after_tool_call.trajectory', mockLogger, mockHandler);
|
|
208
|
+
|
|
209
|
+
// First fire: log is emitted once with the disabled reason.
|
|
210
|
+
guarded({}, {});
|
|
211
|
+
expect(mockLogger.info).toHaveBeenCalledTimes(1);
|
|
212
|
+
expect(mockLogger.info).toHaveBeenCalledWith(
|
|
213
|
+
expect.stringContaining('[PD:surface-guard] SKIP hook:after_tool_call.trajectory'),
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
// Subsequent fires on the same surfaceId: no further log noise, but the
|
|
217
|
+
// handler is still suppressed (observability remains: the no-op returns
|
|
218
|
+
// undefined; the surface is still classified as disabled).
|
|
219
|
+
for (let i = 0; i < 5; i += 1) {
|
|
220
|
+
guarded({}, {});
|
|
221
|
+
}
|
|
222
|
+
expect(mockLogger.info).toHaveBeenCalledTimes(1);
|
|
223
|
+
expect(mockHandler).not.toHaveBeenCalled();
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('logs first fire per surfaceId independently (one log per quiet surface)', () => {
|
|
227
|
+
const mockLogger = { info: vi.fn(), debug: vi.fn() };
|
|
228
|
+
const handler1 = guardHook('hook:after_tool_call.trajectory', mockLogger, vi.fn());
|
|
229
|
+
const handler2 = guardHook('hook:llm_output.trajectory', mockLogger, vi.fn());
|
|
230
|
+
const handler3 = guardHook('hook:subagent_spawning', mockLogger, vi.fn());
|
|
231
|
+
|
|
232
|
+
handler1({}, {});
|
|
233
|
+
handler2({}, {});
|
|
234
|
+
handler3({}, {});
|
|
235
|
+
|
|
236
|
+
expect(mockLogger.info).toHaveBeenCalledTimes(3);
|
|
237
|
+
const calls = mockLogger.info.mock.calls.map(c => String(c[0]));
|
|
238
|
+
expect(calls.some(c => c.includes('hook:after_tool_call.trajectory'))).toBe(true);
|
|
239
|
+
expect(calls.some(c => c.includes('hook:llm_output.trajectory'))).toBe(true);
|
|
240
|
+
expect(calls.some(c => c.includes('hook:subagent_spawning'))).toBe(true);
|
|
241
|
+
});
|
|
163
242
|
});
|
|
164
243
|
|
|
165
244
|
describe('guardService', () => {
|
|
@@ -194,5 +273,68 @@ describe('surface-guard', () => {
|
|
|
194
273
|
const result = guardService('service:nonexistent', mockService);
|
|
195
274
|
expect(result).toBeNull();
|
|
196
275
|
});
|
|
276
|
+
|
|
277
|
+
it('logs once per service surface on first registration (PRI-298 rate-limit)', () => {
|
|
278
|
+
const mockLogger = { info: vi.fn(), debug: vi.fn() };
|
|
279
|
+
const service: OpenClawPluginService = { id: 'test-service' };
|
|
280
|
+
|
|
281
|
+
// First registration call: the disabled reason is logged once.
|
|
282
|
+
const first = guardService('service:trajectory', service, mockLogger);
|
|
283
|
+
expect(first).toBeNull();
|
|
284
|
+
expect(mockLogger.info).toHaveBeenCalledTimes(1);
|
|
285
|
+
expect(mockLogger.info).toHaveBeenCalledWith(
|
|
286
|
+
expect.stringContaining('SKIP service service:trajectory'),
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
// Subsequent guardService calls for the same surfaceId stay silent.
|
|
290
|
+
guardService('service:trajectory', service, mockLogger);
|
|
291
|
+
guardService('service:trajectory', service, mockLogger);
|
|
292
|
+
expect(mockLogger.info).toHaveBeenCalledTimes(1);
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
describe('PRI-298 disabledReason copy', () => {
|
|
297
|
+
it('no quiet surface disabledReason references "Story A" or "Story A\'"', () => {
|
|
298
|
+
const quietOrNonCore = PLUGIN_SURFACE_REGISTRY.filter(
|
|
299
|
+
s => s.category === 'quiet' || s.category === 'gone' || s.category === 'legacy_retire',
|
|
300
|
+
);
|
|
301
|
+
expect(quietOrNonCore.length).toBeGreaterThan(0);
|
|
302
|
+
for (const surface of quietOrNonCore) {
|
|
303
|
+
expect(surface.disabledReason).toBeDefined();
|
|
304
|
+
// MVP residue that should not appear in production log copy.
|
|
305
|
+
expect(surface.disabledReason).not.toMatch(/Story A/);
|
|
306
|
+
expect(surface.disabledReason).not.toMatch(/MVP\s*验收/);
|
|
307
|
+
expect(surface.disabledReason).not.toMatch(/测试任务/);
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it('trajectory hook disabledReason is opt-in and ADR-anchored (PRI-298)', () => {
|
|
312
|
+
const trajectory = PLUGIN_SURFACE_REGISTRY.find(
|
|
313
|
+
s => s.id === 'hook:after_tool_call.trajectory',
|
|
314
|
+
);
|
|
315
|
+
expect(trajectory?.disabledReason).toBeDefined();
|
|
316
|
+
const reason = trajectory!.disabledReason!.toLowerCase();
|
|
317
|
+
// Quiet hook copy is opt-in / opt-out anchored on a real ADR section
|
|
318
|
+
// (no MVP-phase residue, no promise of a feature-flag override that
|
|
319
|
+
// the production guard path does not actually consume — chatgpt P2).
|
|
320
|
+
expect(reason).toContain('opt-in');
|
|
321
|
+
expect(reason).toContain('default off');
|
|
322
|
+
expect(reason).toMatch(/adr-?0014/);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it('no quiet surface disabledReason promises a feature flag override (PRI-298 / chatgpt P2)', () => {
|
|
326
|
+
// The runtime guard path (`isSurfaceEnabled(surfaceId)` with no
|
|
327
|
+
// overrides argument) does not consume `.pd/config.yaml`, so
|
|
328
|
+
// telling operators to "enable via feature flag override" would be
|
|
329
|
+
// an impossible next action. Quiet copy must describe the surface
|
|
330
|
+
// honestly without pointing to a non-existent override path.
|
|
331
|
+
const quiet = PLUGIN_SURFACE_REGISTRY.filter(s => s.category === 'quiet');
|
|
332
|
+
expect(quiet.length).toBeGreaterThan(0);
|
|
333
|
+
for (const surface of quiet) {
|
|
334
|
+
const reason = surface.disabledReason!.toLowerCase();
|
|
335
|
+
expect(reason).not.toContain('enable via feature flag');
|
|
336
|
+
expect(reason).not.toMatch(/enable via .* override/);
|
|
337
|
+
}
|
|
338
|
+
});
|
|
197
339
|
});
|
|
198
340
|
});
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* PRI-288: Quarantine EvolutionWorkerService default startup behind MVP feature flag.
|
|
3
3
|
*
|
|
4
4
|
* Tests prove:
|
|
5
|
-
* 1. Default config (no
|
|
6
|
-
* 2. Explicit enable in
|
|
5
|
+
* 1. Default config (no config.yaml) → EvolutionWorkerService does NOT start.
|
|
6
|
+
* 2. Explicit enable in config.yaml → EvolutionWorkerService starts.
|
|
7
7
|
* 3. Disabled state has structured observability from real helper, not hand-written JSON.
|
|
8
8
|
* 4. api.registerService still works regardless of flag state.
|
|
9
9
|
*
|
|
@@ -60,9 +60,63 @@ function createTempWorkspace(): string {
|
|
|
60
60
|
return dir;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
function
|
|
64
|
-
|
|
65
|
-
|
|
63
|
+
function deepMergeFeatures(
|
|
64
|
+
defaults: Record<string, unknown>,
|
|
65
|
+
overrides: Record<string, unknown>,
|
|
66
|
+
): Record<string, unknown> {
|
|
67
|
+
const result: Record<string, unknown> = { ...defaults };
|
|
68
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
69
|
+
if (
|
|
70
|
+
value != null &&
|
|
71
|
+
typeof value === 'object' &&
|
|
72
|
+
!Array.isArray(value) &&
|
|
73
|
+
Object.hasOwn(result, key) &&
|
|
74
|
+
result[key] != null &&
|
|
75
|
+
typeof result[key] === 'object' &&
|
|
76
|
+
!Array.isArray(result[key])
|
|
77
|
+
) {
|
|
78
|
+
result[key] = { ...(result[key] as Record<string, unknown>), ...(value as Record<string, unknown>) };
|
|
79
|
+
} else {
|
|
80
|
+
result[key] = value;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return result;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function writeConfigYaml(workspaceDir: string, featureOverrides: Record<string, unknown>): void {
|
|
87
|
+
const configPath = path.join(workspaceDir, '.pd', 'config.yaml');
|
|
88
|
+
const defaultFeatures: Record<string, unknown> = {
|
|
89
|
+
prompt: { category: 'core', enabled: true },
|
|
90
|
+
code_tool_hook: { category: 'core', enabled: true },
|
|
91
|
+
defer_archive: { category: 'core', enabled: true },
|
|
92
|
+
correction_observer: { category: 'quiet', enabled: false },
|
|
93
|
+
empathy_observer: { category: 'quiet', enabled: false },
|
|
94
|
+
evolution_worker: { category: 'quiet', enabled: false },
|
|
95
|
+
nocturnal: { category: 'gone', enabled: false },
|
|
96
|
+
};
|
|
97
|
+
const config = {
|
|
98
|
+
version: 1,
|
|
99
|
+
features: deepMergeFeatures(defaultFeatures, featureOverrides),
|
|
100
|
+
runtimeProfiles: {
|
|
101
|
+
'openclaw.default': { type: 'openclaw', source: 'default' },
|
|
102
|
+
},
|
|
103
|
+
internalAgents: {
|
|
104
|
+
defaultRuntime: 'openclaw.default',
|
|
105
|
+
agents: {
|
|
106
|
+
diagnostician: { enabled: true },
|
|
107
|
+
dreamer: { enabled: true },
|
|
108
|
+
scribe: { enabled: true },
|
|
109
|
+
artificer: { enabled: true },
|
|
110
|
+
philosopher: { enabled: false },
|
|
111
|
+
evaluator: { enabled: false },
|
|
112
|
+
rolloutReviewer: { enabled: false },
|
|
113
|
+
trainer: { enabled: false },
|
|
114
|
+
correctionObserver: { enabled: false },
|
|
115
|
+
empathyObserver: { enabled: false },
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
const content = yaml.dump(config, { schema: yaml.JSON_SCHEMA });
|
|
66
120
|
fs.writeFileSync(configPath, content, 'utf8');
|
|
67
121
|
}
|
|
68
122
|
|
|
@@ -112,32 +166,32 @@ describe('PRI-288: EvolutionWorkerService quarantine', () => {
|
|
|
112
166
|
// ── 2. loadFeatureFlagFromWorkspace ──
|
|
113
167
|
|
|
114
168
|
describe('loadFeatureFlagFromWorkspace', () => {
|
|
115
|
-
it('returns enabled=false when no
|
|
169
|
+
it('returns enabled=false when no config.yaml exists', () => {
|
|
116
170
|
const logger = createMockLogger();
|
|
117
171
|
const result = loadFeatureFlagFromWorkspace(workspaceDir, 'evolution_worker', logger);
|
|
118
172
|
expect(result.enabled).toBe(false);
|
|
119
173
|
expect(result.source).toBe('defaults');
|
|
120
174
|
});
|
|
121
175
|
|
|
122
|
-
it('returns enabled=false when
|
|
123
|
-
|
|
176
|
+
it('returns enabled=false when config.yaml has no evolution_worker entry', () => {
|
|
177
|
+
writeConfigYaml(workspaceDir, { prompt: { enabled: true } });
|
|
124
178
|
const logger = createMockLogger();
|
|
125
179
|
const result = loadFeatureFlagFromWorkspace(workspaceDir, 'evolution_worker', logger);
|
|
126
180
|
expect(result.enabled).toBe(false);
|
|
127
181
|
});
|
|
128
182
|
|
|
129
|
-
it('returns enabled=true when
|
|
130
|
-
|
|
183
|
+
it('returns enabled=true when config.yaml explicitly enables evolution_worker', () => {
|
|
184
|
+
writeConfigYaml(workspaceDir, {
|
|
131
185
|
evolution_worker: { enabled: true },
|
|
132
186
|
});
|
|
133
187
|
const logger = createMockLogger();
|
|
134
188
|
const result = loadFeatureFlagFromWorkspace(workspaceDir, 'evolution_worker', logger);
|
|
135
189
|
expect(result.enabled).toBe(true);
|
|
136
|
-
expect(result.source).toBe('
|
|
190
|
+
expect(result.source).toBe('user_config');
|
|
137
191
|
});
|
|
138
192
|
|
|
139
193
|
it('returns enabled=false when YAML is malformed and warning includes error detail', () => {
|
|
140
|
-
const configPath = path.join(workspaceDir, '.pd', '
|
|
194
|
+
const configPath = path.join(workspaceDir, '.pd', 'config.yaml');
|
|
141
195
|
fs.writeFileSync(configPath, ' bad: [yaml: content', 'utf8');
|
|
142
196
|
const logger = createMockLogger();
|
|
143
197
|
const result = loadFeatureFlagFromWorkspace(workspaceDir, 'evolution_worker', logger);
|
|
@@ -152,7 +206,7 @@ describe('PRI-288: EvolutionWorkerService quarantine', () => {
|
|
|
152
206
|
|
|
153
207
|
it('returns defaults when file is unreadable', () => {
|
|
154
208
|
// Create a directory where a file should be — causes read error
|
|
155
|
-
const configPath = path.join(workspaceDir, '.pd', '
|
|
209
|
+
const configPath = path.join(workspaceDir, '.pd', 'config.yaml');
|
|
156
210
|
fs.mkdirSync(configPath, { recursive: true });
|
|
157
211
|
const logger = createMockLogger();
|
|
158
212
|
const result = loadFeatureFlagFromWorkspace(workspaceDir, 'evolution_worker', logger);
|
|
@@ -162,7 +216,7 @@ describe('PRI-288: EvolutionWorkerService quarantine', () => {
|
|
|
162
216
|
|
|
163
217
|
it('rejects dangerous keys (__proto__) and does not enable via prototype pollution', () => {
|
|
164
218
|
// Write raw YAML with __proto__ to test dangerous key rejection on raw parsed output
|
|
165
|
-
|
|
219
|
+
writeConfigYaml(workspaceDir, {
|
|
166
220
|
__proto__: { enabled: true },
|
|
167
221
|
evolution_worker: { enabled: false },
|
|
168
222
|
});
|
|
@@ -175,7 +229,7 @@ describe('PRI-288: EvolutionWorkerService quarantine', () => {
|
|
|
175
229
|
// ── 3. shouldStartEvolutionWorker — real helper, real output ──
|
|
176
230
|
|
|
177
231
|
describe('shouldStartEvolutionWorker gate helper', () => {
|
|
178
|
-
it('returns shouldStart=false by default (no
|
|
232
|
+
it('returns shouldStart=false by default (no config.yaml)', () => {
|
|
179
233
|
const logger = createMockLogger();
|
|
180
234
|
const gate = shouldStartEvolutionWorker(workspaceDir, logger);
|
|
181
235
|
expect(gate.shouldStart).toBe(false);
|
|
@@ -184,13 +238,13 @@ describe('PRI-288: EvolutionWorkerService quarantine', () => {
|
|
|
184
238
|
});
|
|
185
239
|
|
|
186
240
|
it('returns shouldStart=true when explicitly enabled', () => {
|
|
187
|
-
|
|
241
|
+
writeConfigYaml(workspaceDir, {
|
|
188
242
|
evolution_worker: { enabled: true },
|
|
189
243
|
});
|
|
190
244
|
const logger = createMockLogger();
|
|
191
245
|
const gate = shouldStartEvolutionWorker(workspaceDir, logger);
|
|
192
246
|
expect(gate.shouldStart).toBe(true);
|
|
193
|
-
expect(gate.flagSource).toBe('
|
|
247
|
+
expect(gate.flagSource).toBe('user_config');
|
|
194
248
|
expect(gate.disabledInfo).toBeNull();
|
|
195
249
|
});
|
|
196
250
|
|
|
@@ -248,18 +302,18 @@ describe('PRI-288: EvolutionWorkerService quarantine', () => {
|
|
|
248
302
|
|
|
249
303
|
describe('explicit enable: worker starts', () => {
|
|
250
304
|
it('shouldStartEvolutionWorker returns true when enabled in config', () => {
|
|
251
|
-
|
|
305
|
+
writeConfigYaml(workspaceDir, {
|
|
252
306
|
evolution_worker: { enabled: true },
|
|
253
307
|
});
|
|
254
308
|
|
|
255
309
|
const logger = createMockLogger();
|
|
256
310
|
const gate = shouldStartEvolutionWorker(workspaceDir, logger);
|
|
257
311
|
expect(gate.shouldStart).toBe(true);
|
|
258
|
-
expect(gate.flagSource).toBe('
|
|
312
|
+
expect(gate.flagSource).toBe('user_config');
|
|
259
313
|
});
|
|
260
314
|
|
|
261
315
|
it('EvolutionWorkerService.start actually runs when gate is true', () => {
|
|
262
|
-
|
|
316
|
+
writeConfigYaml(workspaceDir, {
|
|
263
317
|
evolution_worker: { enabled: true },
|
|
264
318
|
});
|
|
265
319
|
|
|
@@ -292,17 +346,18 @@ describe('PRI-288: EvolutionWorkerService quarantine', () => {
|
|
|
292
346
|
});
|
|
293
347
|
|
|
294
348
|
it('computeEffectiveFlags preserves core flags even with evolution_worker override', () => {
|
|
295
|
-
|
|
349
|
+
writeConfigYaml(workspaceDir, {
|
|
296
350
|
evolution_worker: { enabled: true },
|
|
297
351
|
});
|
|
298
352
|
|
|
299
|
-
const configPath = path.join(workspaceDir, '.pd', '
|
|
353
|
+
const configPath = path.join(workspaceDir, '.pd', 'config.yaml');
|
|
300
354
|
const raw = fs.readFileSync(configPath, 'utf8');
|
|
301
355
|
const parsed: unknown = yaml.load(raw, { schema: yaml.JSON_SCHEMA });
|
|
302
356
|
|
|
303
357
|
// Use isRecord type guard instead of `as`
|
|
304
358
|
expect(isRecord(parsed)).toBe(true);
|
|
305
|
-
const
|
|
359
|
+
const features = (parsed as Record<string, unknown>).features;
|
|
360
|
+
const flags = computeEffectiveFlags(features as Record<string, unknown>, DEFAULT_FEATURE_FLAGS, configPath);
|
|
306
361
|
expect(flags.flags['prompt']?.enabled).toBe(true);
|
|
307
362
|
expect(flags.flags['code_tool_hook']?.enabled).toBe(true);
|
|
308
363
|
expect(flags.flags['defer_archive']?.enabled).toBe(true);
|
|
@@ -310,17 +365,18 @@ describe('PRI-288: EvolutionWorkerService quarantine', () => {
|
|
|
310
365
|
});
|
|
311
366
|
|
|
312
367
|
it('core flags cannot be disabled by user override', () => {
|
|
313
|
-
|
|
368
|
+
writeConfigYaml(workspaceDir, {
|
|
314
369
|
prompt: { enabled: false },
|
|
315
370
|
code_tool_hook: { enabled: false },
|
|
316
371
|
});
|
|
317
372
|
|
|
318
|
-
const configPath = path.join(workspaceDir, '.pd', '
|
|
373
|
+
const configPath = path.join(workspaceDir, '.pd', 'config.yaml');
|
|
319
374
|
const raw = fs.readFileSync(configPath, 'utf8');
|
|
320
375
|
const parsed: unknown = yaml.load(raw, { schema: yaml.JSON_SCHEMA });
|
|
321
376
|
|
|
322
377
|
expect(isRecord(parsed)).toBe(true);
|
|
323
|
-
const
|
|
378
|
+
const features = (parsed as Record<string, unknown>).features;
|
|
379
|
+
const flags = computeEffectiveFlags(features as Record<string, unknown>, DEFAULT_FEATURE_FLAGS, configPath);
|
|
324
380
|
expect(flags.flags['prompt']?.enabled).toBe(true); // core cannot be disabled
|
|
325
381
|
expect(flags.flags['code_tool_hook']?.enabled).toBe(true); // core cannot be disabled
|
|
326
382
|
expect(flags.warnings.length).toBeGreaterThan(0); // warnings about core override attempt
|
|
@@ -331,7 +387,7 @@ describe('PRI-288: EvolutionWorkerService quarantine', () => {
|
|
|
331
387
|
|
|
332
388
|
describe('no confirm-first gate regression', () => {
|
|
333
389
|
it('no PLAN.md or confirm-first files are created in workspace', () => {
|
|
334
|
-
|
|
390
|
+
writeConfigYaml(workspaceDir, {
|
|
335
391
|
evolution_worker: { enabled: false },
|
|
336
392
|
});
|
|
337
393
|
|
|
@@ -28,9 +28,63 @@ function createTempWorkspace(): string {
|
|
|
28
28
|
return dir;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
function
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
function deepMergeFeatures(
|
|
32
|
+
defaults: Record<string, unknown>,
|
|
33
|
+
overrides: Record<string, unknown>,
|
|
34
|
+
): Record<string, unknown> {
|
|
35
|
+
const result: Record<string, unknown> = { ...defaults };
|
|
36
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
37
|
+
if (
|
|
38
|
+
value != null &&
|
|
39
|
+
typeof value === 'object' &&
|
|
40
|
+
!Array.isArray(value) &&
|
|
41
|
+
Object.hasOwn(result, key) &&
|
|
42
|
+
result[key] != null &&
|
|
43
|
+
typeof result[key] === 'object' &&
|
|
44
|
+
!Array.isArray(result[key])
|
|
45
|
+
) {
|
|
46
|
+
result[key] = { ...(result[key] as Record<string, unknown>), ...(value as Record<string, unknown>) };
|
|
47
|
+
} else {
|
|
48
|
+
result[key] = value;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function writeConfigYaml(workspaceDir: string, featureOverrides: Record<string, unknown>): void {
|
|
55
|
+
const configPath = path.join(workspaceDir, '.pd', 'config.yaml');
|
|
56
|
+
const defaultFeatures: Record<string, unknown> = {
|
|
57
|
+
prompt: { category: 'core', enabled: true },
|
|
58
|
+
code_tool_hook: { category: 'core', enabled: true },
|
|
59
|
+
defer_archive: { category: 'core', enabled: true },
|
|
60
|
+
correction_observer: { category: 'quiet', enabled: false },
|
|
61
|
+
empathy_observer: { category: 'quiet', enabled: false },
|
|
62
|
+
evolution_worker: { category: 'quiet', enabled: false },
|
|
63
|
+
nocturnal: { category: 'gone', enabled: false },
|
|
64
|
+
};
|
|
65
|
+
const config = {
|
|
66
|
+
version: 1,
|
|
67
|
+
features: deepMergeFeatures(defaultFeatures, featureOverrides),
|
|
68
|
+
runtimeProfiles: {
|
|
69
|
+
'openclaw.default': { type: 'openclaw', source: 'default' },
|
|
70
|
+
},
|
|
71
|
+
internalAgents: {
|
|
72
|
+
defaultRuntime: 'openclaw.default',
|
|
73
|
+
agents: {
|
|
74
|
+
diagnostician: { enabled: true },
|
|
75
|
+
dreamer: { enabled: true },
|
|
76
|
+
scribe: { enabled: true },
|
|
77
|
+
artificer: { enabled: true },
|
|
78
|
+
philosopher: { enabled: false },
|
|
79
|
+
evaluator: { enabled: false },
|
|
80
|
+
rolloutReviewer: { enabled: false },
|
|
81
|
+
trainer: { enabled: false },
|
|
82
|
+
correctionObserver: { enabled: false },
|
|
83
|
+
empathyObserver: { enabled: false },
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
const content = yaml.dump(config, { schema: yaml.JSON_SCHEMA });
|
|
34
88
|
fs.writeFileSync(configPath, content, 'utf8');
|
|
35
89
|
}
|
|
36
90
|
|
|
@@ -151,6 +205,9 @@ describe('PRI-294: CorrectionObserver independence from EvolutionWorker', () =>
|
|
|
151
205
|
});
|
|
152
206
|
|
|
153
207
|
it('CorrectionObserver starts when EvolutionWorker is disabled (default)', () => {
|
|
208
|
+
writeConfigYaml(workspaceDir, {
|
|
209
|
+
correction_observer: { enabled: true },
|
|
210
|
+
});
|
|
154
211
|
const logger = createMockLogger();
|
|
155
212
|
|
|
156
213
|
// Verify EvolutionWorker is disabled
|
|
@@ -164,8 +221,9 @@ describe('PRI-294: CorrectionObserver independence from EvolutionWorker', () =>
|
|
|
164
221
|
});
|
|
165
222
|
|
|
166
223
|
it('CorrectionObserver starts when EvolutionWorker is explicitly enabled', () => {
|
|
167
|
-
|
|
224
|
+
writeConfigYaml(workspaceDir, {
|
|
168
225
|
evolution_worker: { enabled: true },
|
|
226
|
+
correction_observer: { enabled: true },
|
|
169
227
|
});
|
|
170
228
|
const logger = createMockLogger();
|
|
171
229
|
|
|
@@ -177,7 +235,7 @@ describe('PRI-294: CorrectionObserver independence from EvolutionWorker', () =>
|
|
|
177
235
|
});
|
|
178
236
|
|
|
179
237
|
it('CorrectionObserver can be independently disabled', () => {
|
|
180
|
-
|
|
238
|
+
writeConfigYaml(workspaceDir, {
|
|
181
239
|
correction_observer: { enabled: false },
|
|
182
240
|
});
|
|
183
241
|
const logger = createMockLogger();
|
|
@@ -494,9 +494,11 @@ describe('Runtime V2 prompt activation — additional guard tests', () => {
|
|
|
494
494
|
|
|
495
495
|
it('malformed DB/config input fails loud with warning', async () => {
|
|
496
496
|
const pdDir = path.join(tempWorkspaceDir, '.pd');
|
|
497
|
+
// PRI-305/PRI-307: Write a malformed .pd/config.yaml with dangerous keys
|
|
498
|
+
// The core validator rejects __proto__ and constructor as dangerous keys
|
|
497
499
|
fs.writeFileSync(
|
|
498
|
-
path.join(pdDir, '
|
|
499
|
-
'__proto__:\n enabled: true\
|
|
500
|
+
path.join(pdDir, 'config.yaml'),
|
|
501
|
+
'version: 1\nfeatures:\n __proto__:\n category: core\n enabled: true\n prompt:\n category: core\n enabled: true\n constructor:\n category: core\n enabled: false\nruntimeProfiles:\n openclaw.default:\n type: openclaw\n source: default\ninternalAgents:\n defaultRuntime: openclaw.default\n agents:\n diagnostician:\n enabled: true\n dreamer:\n enabled: true\n scribe:\n enabled: true\n artificer:\n enabled: true\n philosopher:\n enabled: false\n evaluator:\n enabled: false\n rolloutReviewer:\n enabled: false\n trainer:\n enabled: false\n correctionObserver:\n enabled: false\n empathyObserver:\n enabled: false\n',
|
|
500
502
|
'utf8',
|
|
501
503
|
);
|
|
502
504
|
|
|
@@ -507,10 +509,14 @@ describe('Runtime V2 prompt activation — additional guard tests', () => {
|
|
|
507
509
|
const result = await reader.readActivatedPrinciples();
|
|
508
510
|
|
|
509
511
|
const warnCalls = warnSpy.mock.calls.map((c: unknown[]) => String(c[0]));
|
|
512
|
+
// PRI-305/PRI-307: Core validator rejects dangerous keys as errors.
|
|
513
|
+
// The plugin config loader logs config errors as warnings.
|
|
510
514
|
const hasDangerousKeyWarning = warnCalls.some(
|
|
511
|
-
(c: string) => c.includes('dangerous key') || c.includes('__proto__') || c.includes('constructor'),
|
|
515
|
+
(c: string) => c.includes('dangerous key') || c.includes('__proto__') || c.includes('constructor') || c.includes('Config error'),
|
|
512
516
|
);
|
|
513
517
|
expect(hasDangerousKeyWarning).toBe(true);
|
|
518
|
+
// With malformed config, defaults are used (prompt enabled by default),
|
|
519
|
+
// but no DB data exists, so principles should be empty
|
|
514
520
|
expect(result.principles).toEqual([]);
|
|
515
521
|
});
|
|
516
522
|
|