principles-disciple 1.40.0 → 1.42.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.
Files changed (52) hide show
  1. package/.planning/codebase/ARCHITECTURE.md +157 -0
  2. package/.planning/codebase/CONCERNS.md +145 -0
  3. package/.planning/codebase/CONVENTIONS.md +148 -0
  4. package/.planning/codebase/INTEGRATIONS.md +81 -0
  5. package/.planning/codebase/STACK.md +87 -0
  6. package/.planning/codebase/STRUCTURE.md +193 -0
  7. package/.planning/codebase/TESTING.md +243 -0
  8. package/esbuild.config.js +32 -3
  9. package/openclaw.plugin.json +1 -1
  10. package/package.json +2 -1
  11. package/scripts/compile-principles.mjs +94 -0
  12. package/scripts/sync-plugin.mjs +96 -281
  13. package/src/commands/pain.ts +12 -5
  14. package/src/commands/promote-impl.ts +13 -7
  15. package/src/commands/rollback.ts +10 -3
  16. package/src/core/event-log.ts +8 -6
  17. package/src/core/evolution-types.ts +33 -1
  18. package/src/core/principle-compiler/code-validator.ts +120 -0
  19. package/src/core/principle-compiler/compiler.ts +242 -0
  20. package/src/core/principle-compiler/index.ts +10 -0
  21. package/src/core/principle-compiler/ledger-registrar.ts +107 -0
  22. package/src/core/principle-compiler/template-generator.ts +108 -0
  23. package/src/core/reflection/reflection-context.ts +228 -0
  24. package/src/hooks/message-sanitize.ts +18 -5
  25. package/src/hooks/prompt.ts +15 -4
  26. package/src/hooks/subagent.ts +2 -3
  27. package/src/http/principles-console-route.ts +21 -4
  28. package/src/service/evolution-worker.ts +89 -365
  29. package/src/service/queue-io.ts +375 -0
  30. package/src/service/queue-migration.ts +122 -0
  31. package/src/service/sleep-cycle.ts +157 -0
  32. package/src/service/subagent-workflow/runtime-direct-driver.ts +1 -1
  33. package/src/service/workflow-watchdog.ts +168 -0
  34. package/src/tools/deep-reflect.ts +22 -11
  35. package/src/types/event-payload.ts +80 -0
  36. package/src/types/queue.ts +70 -0
  37. package/src/utils/file-lock.ts +2 -2
  38. package/src/utils/io.ts +11 -3
  39. package/tests/core/code-validator.test.ts +197 -0
  40. package/tests/core/evolution-migration.test.ts +325 -1
  41. package/tests/core/ledger-registrar.test.ts +232 -0
  42. package/tests/core/principle-compiler.test.ts +348 -0
  43. package/tests/core/queue-purge.test.ts +337 -0
  44. package/tests/core/reflection-context.test.ts +356 -0
  45. package/tests/core/template-generator.test.ts +101 -0
  46. package/tests/fixtures/legacy-queue-v1.json +74 -0
  47. package/tests/integration/principle-compiler-e2e.test.ts +335 -0
  48. package/tests/queue/async-lock.test.ts +200 -0
  49. package/tests/service/evolution-worker.queue.test.ts +296 -0
  50. package/tests/service/queue-io.test.ts +229 -0
  51. package/tests/service/queue-migration.test.ts +147 -0
  52. package/tests/service/workflow-watchdog.test.ts +372 -0
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Ledger Registrar (Task 4)
3
+ *
4
+ * Registers a compiled rule into the principle tree ledger:
5
+ * 1. Creates a LedgerRule with type 'gate', enforcement 'block', status 'proposed'
6
+ * 2. Creates an Implementation with type 'code', lifecycleState 'candidate'
7
+ *
8
+ * IDEMPOTENCY: If the rule already exists, returns existing registration.
9
+ * ROLLBACK: If implementation creation fails after rule creation, attempts cleanup.
10
+ */
11
+
12
+ import { createRule, createImplementation, loadLedger, deleteRule, type LedgerRule } from '../principle-tree-ledger.js';
13
+
14
+ export interface RegisterInput {
15
+ principleId: string;
16
+ codeContent: string;
17
+ coversCondition: string;
18
+ }
19
+
20
+ export interface RegisterResult {
21
+ success: boolean;
22
+ ruleId: string;
23
+ implementationId: string;
24
+ codePath: string;
25
+ }
26
+
27
+ /**
28
+ * Register a compiled rule for a principle in the ledger.
29
+ *
30
+ * Idempotent: if rule already exists, returns existing registration.
31
+ * Atomic: if implementation creation fails, rolls back the rule.
32
+ */
33
+ export function registerCompiledRule(stateDir: string, input: RegisterInput): RegisterResult {
34
+ const { principleId, codeContent, coversCondition } = input;
35
+
36
+ const ruleId = `R_${principleId}_auto`;
37
+ const implementationId = `IMPL_${principleId}_auto`;
38
+ const codePath = `compiled-rules/${principleId}/rule.ts`;
39
+
40
+ // Idempotency: skip if rule already exists
41
+ const existingLedger = loadLedger(stateDir);
42
+ if (existingLedger.tree.rules[ruleId]) {
43
+ const existingRule = existingLedger.tree.rules[ruleId];
44
+ return {
45
+ success: true,
46
+ ruleId,
47
+ implementationId: existingRule.implementationIds[0] ?? implementationId,
48
+ codePath,
49
+ };
50
+ }
51
+
52
+ const now = new Date().toISOString();
53
+
54
+ // Step 1: Create the rule
55
+ const rule: LedgerRule = {
56
+ id: ruleId,
57
+ version: 1,
58
+ name: `Auto-compiled rule for ${principleId}`,
59
+ description: `Automatically compiled gate rule generated from principle ${principleId}`,
60
+ type: 'gate',
61
+ triggerCondition: coversCondition,
62
+ enforcement: 'block',
63
+ action: codeContent,
64
+ principleId,
65
+ status: 'proposed',
66
+ coverageRate: 0,
67
+ falsePositiveRate: 0,
68
+ implementationIds: [],
69
+ createdAt: now,
70
+ updatedAt: now,
71
+ };
72
+
73
+ createRule(stateDir, rule);
74
+
75
+ // Step 2: Create the implementation (with rollback on failure)
76
+ try {
77
+ const implementation = {
78
+ id: implementationId,
79
+ ruleId,
80
+ type: 'code' as const,
81
+ path: codePath,
82
+ version: '1',
83
+ coversCondition,
84
+ coveragePercentage: 100,
85
+ lifecycleState: 'active' as const,
86
+ createdAt: now,
87
+ updatedAt: now,
88
+ };
89
+
90
+ createImplementation(stateDir, implementation);
91
+ } catch (implError) {
92
+ // Rollback: remove the orphaned rule
93
+ try {
94
+ deleteRule(stateDir, ruleId);
95
+ } catch {
96
+ // Best-effort rollback — log but don't mask the original error
97
+ }
98
+ throw implError;
99
+ }
100
+
101
+ return {
102
+ success: true,
103
+ ruleId,
104
+ implementationId,
105
+ codePath,
106
+ };
107
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Template Generator for Principle Compiler
3
+ *
4
+ * Generates RuleHost sandbox code from PainPattern descriptors.
5
+ * Produces self-contained JS modules with `export const meta` and
6
+ * `export function evaluate(input)` that can be loaded at runtime.
7
+ *
8
+ * SECURITY: All interpolated values use JSON.stringify or safe helpers
9
+ * to prevent code injection through principleId, coversCondition, or regex patterns.
10
+ */
11
+
12
+ export interface PainPattern {
13
+ toolName: string;
14
+ pathRegex?: string;
15
+ commandRegex?: string;
16
+ contentRegex?: string;
17
+ errorType?: string;
18
+ }
19
+
20
+ /**
21
+ * Derives the auto-rule display name from a principle ID.
22
+ */
23
+ function toAutoName(principleId: string): string {
24
+ return `Auto_${principleId}`;
25
+ }
26
+
27
+ /**
28
+ * Derives the auto-rule ID from a principle ID.
29
+ * Must match ledger-registrar convention: "P_066" => "R_P_066_auto"
30
+ */
31
+ function toAutoRuleId(principleId: string): string {
32
+ return `R_${principleId}_auto`;
33
+ }
34
+
35
+ /**
36
+ * Builds a single `if` branch for a pain pattern.
37
+ *
38
+ * SECURITY: Uses `new RegExp(JSON.stringify(...))` instead of regex literals
39
+ * to prevent code injection through pathRegex/commandRegex/contentRegex values.
40
+ * principleId in reason uses JSON.stringify to prevent string breakout.
41
+ */
42
+ function buildBranch(principleId: string, pattern: PainPattern): string {
43
+ const conditions: string[] = [];
44
+
45
+ conditions.push(`input.action.toolName === ${JSON.stringify(pattern.toolName)}`);
46
+
47
+ if (pattern.pathRegex) {
48
+ conditions.push(`new RegExp(${JSON.stringify(pattern.pathRegex)}).test(input.action.normalizedPath || '')`);
49
+ }
50
+
51
+ if (pattern.commandRegex) {
52
+ conditions.push(`new RegExp(${JSON.stringify(pattern.commandRegex)}).test(input.action.paramsSummary.command || '')`);
53
+ }
54
+
55
+ if (pattern.contentRegex) {
56
+ conditions.push(
57
+ `new RegExp(${JSON.stringify(pattern.contentRegex)}).test(input.action.paramsSummary.content || input.action.paramsSummary.new_string || '')`,
58
+ );
59
+ }
60
+
61
+ const guard = conditions.join(' && ');
62
+ const reason = `[${principleId}] Blocked by auto-generated rule`;
63
+
64
+ return (
65
+ ` if (${guard}) {\n` +
66
+ ` return { decision: 'block', matched: true, reason: ${JSON.stringify(reason)} };\n` +
67
+ ` }`
68
+ );
69
+ }
70
+
71
+ /**
72
+ * Generates sandbox-ready JS code from a principle ID and pain patterns.
73
+ *
74
+ * Returns `null` when `patterns` is empty.
75
+ */
76
+ export function generateFromTemplate(
77
+ principleId: string,
78
+ coversCondition: string,
79
+ patterns: PainPattern[],
80
+ ): string | null {
81
+ if (patterns.length === 0) {
82
+ return null;
83
+ }
84
+
85
+ const name = toAutoName(principleId);
86
+ const ruleId = toAutoRuleId(principleId);
87
+ const compiledAt = new Date().toISOString();
88
+
89
+ const branches = patterns
90
+ .map((p) => buildBranch(principleId, p))
91
+ .join('\n');
92
+
93
+ return (
94
+ `// Auto-generated by Principle Compiler\n` +
95
+ `export const meta = {\n` +
96
+ ` name: ${JSON.stringify(name)},\n` +
97
+ ` version: '1.0.0',\n` +
98
+ ` ruleId: ${JSON.stringify(ruleId)},\n` +
99
+ ` coversCondition: ${JSON.stringify(coversCondition)},\n` +
100
+ ` compiledAt: ${JSON.stringify(compiledAt)},\n` +
101
+ ` sourcePrincipleId: ${JSON.stringify(principleId)},\n` +
102
+ `};\n\n` +
103
+ `export function evaluate(input) {\n` +
104
+ `${branches}\n` +
105
+ ` return { matched: false };\n` +
106
+ `}\n`
107
+ );
108
+ }
@@ -0,0 +1,228 @@
1
+ /**
2
+ * ReflectionContextCollector — Unified Pipeline Input
3
+ * ====================================================
4
+ *
5
+ * PURPOSE: Collect all grounding context for a principle into a single
6
+ * ReflectionContext object. This is the input to the nocturnal reflection
7
+ * pipeline: principle + painEvents + sessionSnapshot + lineage.
8
+ *
9
+ * DESIGN DECISIONS:
10
+ * - If a principle has no derivedFromPainIds, collect() returns null
11
+ * (nothing to ground code on).
12
+ * - painId -> sessionId resolution is a known gap. For now, we attempt
13
+ * best-effort lookup but return what we have with sessionSnapshot = null
14
+ * if we can't resolve.
15
+ *
16
+ * REUSES:
17
+ * - principle-tree-ledger: loadLedger() for principle lookup
18
+ * - nocturnal-trajectory-extractor: for session snapshots
19
+ * - trajectory: TrajectoryDatabase for pain event queries
20
+ */
21
+
22
+ import { loadLedger, type LedgerPrinciple } from '../principle-tree-ledger.js';
23
+ import {
24
+ NocturnalTrajectoryExtractor,
25
+ type NocturnalPainEvent,
26
+ type NocturnalSessionSnapshot,
27
+ } from '../nocturnal-trajectory-extractor.js';
28
+ import type { TrajectoryDatabase } from '../trajectory.js';
29
+ import type { Principle } from '../../types/principle-tree-schema.js';
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Types
33
+ // ---------------------------------------------------------------------------
34
+
35
+ /**
36
+ * Unified reflection context for the nocturnal pipeline.
37
+ */
38
+ export interface ReflectionContext {
39
+ /** The principle being reflected upon */
40
+ principle: Principle;
41
+ /** Pain events associated with this principle (via derivedFromPainIds) */
42
+ painEvents: NocturnalPainEvent[];
43
+ /** Session snapshot if resolvable, null otherwise */
44
+ sessionSnapshot: NocturnalSessionSnapshot | null;
45
+ /** Lineage metadata connecting principle to source pain signals */
46
+ lineage: {
47
+ sourcePainIds: string[];
48
+ sessionId: string | null;
49
+ };
50
+ }
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Collector
54
+ // ---------------------------------------------------------------------------
55
+
56
+ /**
57
+ * Collects ReflectionContext for principles by joining ledger data with
58
+ * trajectory data.
59
+ */
60
+ export class ReflectionContextCollector {
61
+ private readonly stateDir: string;
62
+ private readonly trajectory: TrajectoryDatabase;
63
+
64
+ constructor(stateDir: string, trajectory: TrajectoryDatabase) {
65
+ this.stateDir = stateDir;
66
+ this.trajectory = trajectory;
67
+ }
68
+
69
+ /**
70
+ * Collect full reflection context for a single principle.
71
+ *
72
+ * Returns null if:
73
+ * - The principle is not found in the ledger
74
+ * - The principle has no derivedFromPainIds (nothing to ground on)
75
+ */
76
+ collect(principleId: string): ReflectionContext | null {
77
+ const ledger = loadLedger(this.stateDir);
78
+ const principle = ledger.tree.principles[principleId];
79
+
80
+ if (!principle) {
81
+ return null;
82
+ }
83
+
84
+ if (!principle.derivedFromPainIds || principle.derivedFromPainIds.length === 0) {
85
+ return null;
86
+ }
87
+
88
+ return this.buildContext(principle);
89
+ }
90
+
91
+ /**
92
+ * Collect reflection contexts for multiple principles, optionally filtered.
93
+ *
94
+ * Skips principles without derivedFromPainIds.
95
+ */
96
+ collectBatch(filter?: { status?: string }): ReflectionContext[] {
97
+ const ledger = loadLedger(this.stateDir);
98
+ const principles = Object.values(ledger.tree.principles);
99
+
100
+ const results: ReflectionContext[] = [];
101
+
102
+ for (const principle of principles) {
103
+ // Apply status filter if provided
104
+ if (filter?.status && principle.status !== filter.status) {
105
+ continue;
106
+ }
107
+
108
+ // Skip principles without pain grounding
109
+ if (!principle.derivedFromPainIds || principle.derivedFromPainIds.length === 0) {
110
+ continue;
111
+ }
112
+
113
+ const ctx = this.buildContext(principle);
114
+ if (ctx) {
115
+ results.push(ctx);
116
+ }
117
+ }
118
+
119
+ return results;
120
+ }
121
+
122
+ // -----------------------------------------------------------------------
123
+ // Private helpers
124
+ // -----------------------------------------------------------------------
125
+
126
+ /**
127
+ * Build a ReflectionContext from a principle.
128
+ *
129
+ * Attempts to resolve painIds to sessions via best-effort lookup.
130
+ * Since painId -> sessionId mapping is a known gap, we may return
131
+ * empty painEvents and null sessionSnapshot.
132
+ */
133
+ private buildContext(principle: LedgerPrinciple): ReflectionContext {
134
+ const sourcePainIds = principle.derivedFromPainIds;
135
+
136
+ // Best-effort: try to find a session containing pain events related to this principle.
137
+ // The pain_events table uses auto-increment IDs, not the string painIds stored in
138
+ // derivedFromPainIds. This is the known gap — for now we attempt session resolution
139
+ // but gracefully handle the case where we can't match.
140
+ const { painEvents, sessionId } = this.resolvePainEvents(sourcePainIds);
141
+
142
+ // If we found a session, get the snapshot
143
+ let sessionSnapshot: NocturnalSessionSnapshot | null = null;
144
+ if (sessionId) {
145
+ const extractor = new NocturnalTrajectoryExtractor(this.trajectory);
146
+ sessionSnapshot = extractor.getNocturnalSessionSnapshot(sessionId);
147
+ }
148
+
149
+ return {
150
+ principle,
151
+ painEvents,
152
+ sessionSnapshot,
153
+ lineage: {
154
+ sourcePainIds,
155
+ sessionId,
156
+ },
157
+ };
158
+ }
159
+
160
+ /**
161
+ * Attempt to resolve painIds to actual pain events and a session.
162
+ *
163
+ * Two-phase strategy:
164
+ * 1. Exact ID match: sourcePainIds are stringified pain_events row IDs.
165
+ * If any match String(pe.id) exactly, use those and stop.
166
+ * 2. Heuristic fallback: substring match on reason/origin fields.
167
+ * Only used when no exact matches are found.
168
+ */
169
+ private resolvePainEvents(sourcePainIds: string[]): {
170
+ painEvents: NocturnalPainEvent[];
171
+ sessionId: string | null;
172
+ } {
173
+ const sessions = this.trajectory.listRecentSessions({ limit: 100 });
174
+ const sourcePainIdSet = new Set(sourcePainIds);
175
+
176
+ const exactMatches: NocturnalPainEvent[] = [];
177
+ const heuristicMatches: NocturnalPainEvent[] = [];
178
+ let exactSessionId: string | null = null;
179
+ let heuristicSessionId: string | null = null;
180
+
181
+ for (const session of sessions) {
182
+ const sessionPainEvents = this.trajectory.listPainEventsForSession(session.sessionId);
183
+
184
+ for (const pe of sessionPainEvents) {
185
+ // Phase 1: exact ID match
186
+ if (sourcePainIdSet.has(String(pe.id))) {
187
+ exactMatches.push({
188
+ source: pe.source,
189
+ score: pe.score,
190
+ severity: pe.severity,
191
+ reason: pe.reason,
192
+ createdAt: pe.createdAt,
193
+ });
194
+ if (!exactSessionId) {
195
+ exactSessionId = session.sessionId;
196
+ }
197
+ continue;
198
+ }
199
+
200
+ // Phase 2: heuristic substring match on reason/origin only
201
+ const peText = [pe.reason, pe.origin].filter(Boolean);
202
+ const isMatch = sourcePainIds.some((painId) =>
203
+ peText.some((field) => field?.includes(painId)),
204
+ );
205
+
206
+ if (isMatch) {
207
+ heuristicMatches.push({
208
+ source: pe.source,
209
+ score: pe.score,
210
+ severity: pe.severity,
211
+ reason: pe.reason,
212
+ createdAt: pe.createdAt,
213
+ });
214
+ if (!heuristicSessionId) {
215
+ heuristicSessionId = session.sessionId;
216
+ }
217
+ }
218
+ }
219
+ }
220
+
221
+ // Prefer exact matches over heuristic matches
222
+ if (exactMatches.length > 0) {
223
+ return { painEvents: exactMatches, sessionId: exactSessionId };
224
+ }
225
+
226
+ return { painEvents: heuristicMatches, sessionId: heuristicSessionId };
227
+ }
228
+ }
@@ -6,6 +6,21 @@ const INTERNAL_TAG_PATTERNS = [
6
6
  /<empathy\s+[^>]*\/?>(?:<\/empathy>)?/gi,
7
7
  ];
8
8
 
9
+ /**
10
+ * Type predicate: true if msg is an assistant message with content.
11
+ * Used for safe narrowing after spread operations on message union.
12
+ */
13
+ function isAssistantMessageWithContent(
14
+ msg: unknown
15
+ ): msg is { role: 'assistant'; content: string } {
16
+ return (
17
+ typeof msg === 'object' &&
18
+ msg !== null &&
19
+ (msg as { role?: string }).role === 'assistant' &&
20
+ typeof (msg as { content?: unknown }).content === 'string'
21
+ );
22
+ }
23
+
9
24
  export function sanitizeAssistantText(text: string): string {
10
25
  let result = text;
11
26
  for (const pattern of INTERNAL_TAG_PATTERNS) {
@@ -23,11 +38,10 @@ export function handleBeforeMessageWrite(
23
38
  const msg = event.message as { role?: string; content?: unknown } | undefined;
24
39
  if (!msg || msg.role !== 'assistant') return;
25
40
 
26
- if (typeof msg.content === 'string') {
41
+ if (isAssistantMessageWithContent(msg)) {
27
42
  const sanitized = sanitizeAssistantText(msg.content);
28
43
  if (sanitized !== msg.content) {
29
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Reason: message content is dynamically modified, type preserved from event.message union
30
- return { message: { ...msg, content: sanitized } as any };
44
+ return { message: { ...msg, content: sanitized } };
31
45
  }
32
46
  return;
33
47
  }
@@ -39,8 +53,7 @@ export function handleBeforeMessageWrite(
39
53
  }
40
54
  return part;
41
55
  });
42
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Reason: message content is dynamically modified, type preserved from event.message union
43
- return { message: { ...msg, content: next } as any };
56
+ return { message: { ...msg, content: next } };
44
57
  }
45
58
 
46
59
  return;
@@ -20,6 +20,17 @@ import {
20
20
  } from '../core/empathy-keyword-matcher.js';
21
21
  import { severityToPenalty, DEFAULT_EMPATHY_KEYWORD_CONFIG } from '../core/empathy-types.js';
22
22
  import { CorrectionCueLearner } from '../core/correction-cue-learner.js';
23
+ import type { PluginRuntimeSubagent } from '../service/subagent-workflow/runtime-direct-driver.js';
24
+
25
+ /**
26
+ * Type assertion: OpenClaw SDK subagent -> workflow manager subagent type.
27
+ * Both types are structurally identical but come from different import paths.
28
+ */
29
+ function toWorkflowSubagent(
30
+ subagent: NonNullable<OpenClawPluginApi['runtime']>['subagent']
31
+ ): PluginRuntimeSubagent {
32
+ return subagent as unknown as PluginRuntimeSubagent;
33
+ }
23
34
 
24
35
  // ---------------------------------------------------------------------------
25
36
  // Static file cache — avoids re-reading rarely-changing files every message
@@ -590,8 +601,8 @@ The empathy observer subagent handles pain detection independently.
590
601
  const empathyManager = new EmpathyObserverWorkflowManager({
591
602
  workspaceDir,
592
603
  logger: api.logger ?? console,
593
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Reason: runtimeSubagent has structurally compatible shape but differs from workflow manager's subagent type
594
- subagent: runtimeSubagent as any,
604
+
605
+ subagent: toWorkflowSubagent(runtimeSubagent),
595
606
  });
596
607
  empathyManager.startWorkflow(empathyObserverWorkflowSpec, {
597
608
  parentSessionId: sessionId,
@@ -626,8 +637,8 @@ The empathy observer subagent handles pain detection independently.
626
637
  const empathyManager = new EmpathyObserverWorkflowManager({
627
638
  workspaceDir,
628
639
  logger: api.logger ?? console,
629
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Reason: api.runtime.subagent has structurally compatible shape but differs from workflow manager's subagent type
630
- subagent: api.runtime.subagent as any,
640
+
641
+ subagent: toWorkflowSubagent(api.runtime.subagent),
631
642
  });
632
643
 
633
644
  empathyManager.startWorkflow(empathyObserverWorkflowSpec, {
@@ -14,7 +14,7 @@ import type { WorkflowManager } from '../service/subagent-workflow/types.js';
14
14
  * Used by the subagent_ended hook to dispatch lifecycle recovery to the right manager.
15
15
  */
16
16
 
17
- // eslint-disable-next-line @typescript-eslint/max-params
17
+
18
18
  function createWorkflowManagerForType(
19
19
  workflowType: string,
20
20
  workspaceDir: string,
@@ -25,9 +25,8 @@ function createWorkflowManagerForType(
25
25
  info: (m: string) => logger.info(String(m)),
26
26
  warn: (m: string) => logger.warn(String(m)),
27
27
  error: (m: string) => logger.error(String(m)),
28
-
29
28
  debug: () => { /* no-op */ },
30
- } as unknown as PluginLogger;
29
+ };
31
30
 
32
31
  switch (workflowType) {
33
32
  case 'empathy-observer':
@@ -1,3 +1,4 @@
1
+ import * as crypto from 'crypto';
1
2
  import fs from 'fs';
2
3
  import path from 'path';
3
4
  import type { IncomingMessage, ServerResponse } from 'node:http';
@@ -96,7 +97,7 @@ function createService(api: OpenClawPluginApi): ControlUiQueryService {
96
97
  }
97
98
 
98
99
 
99
- // eslint-disable-next-line @typescript-eslint/max-params
100
+
100
101
  function handleApiRoute(
101
102
  api: OpenClawPluginApi,
102
103
  pathname: string,
@@ -105,13 +106,13 @@ function handleApiRoute(
105
106
  ): Promise<boolean> | boolean {
106
107
  // Check authentication for API routes
107
108
 
108
- // eslint-disable-next-line @typescript-eslint/no-use-before-define
109
+
109
110
  if (!validateGatewayAuth(req)) {
110
111
  json(res, 401, { error: 'unauthorized', message: 'Valid Gateway token required.' });
111
112
  return true;
112
113
  }
113
114
 
114
- // eslint-disable-next-line @typescript-eslint/init-declarations
115
+
115
116
  let service: ControlUiQueryService;
116
117
  try {
117
118
  service = createService(api);
@@ -566,7 +567,23 @@ function validateGatewayAuth(req: IncomingMessage): boolean {
566
567
  const authHeader = (req.headers?.authorization as string) || '';
567
568
  const tokenMatch = /^Bearer\s+(.+)$/i.exec(authHeader);
568
569
  const providedToken = tokenMatch?.[1];
569
- return providedToken === gatewayToken;
570
+
571
+ if (!providedToken) {
572
+ return false;
573
+ }
574
+
575
+ // Constant-time comparison to prevent timing attacks (per D-07)
576
+ // Use Buffer comparison — both tokens must be same length for timingSafeEqual
577
+ const providedBuffer = Buffer.from(providedToken, 'utf8');
578
+ const expectedBuffer = Buffer.from(gatewayToken, 'utf8');
579
+
580
+ if (providedBuffer.length !== expectedBuffer.length) {
581
+ // Length mismatch — fail fast but without timing leak
582
+ // Return false immediately rather than letting timingSafeEqual throw
583
+ return false;
584
+ }
585
+
586
+ return crypto.timingSafeEqual(providedBuffer, expectedBuffer);
570
587
  }
571
588
 
572
589
  /**