@reefclaw/openclaw-plugin 0.1.13 → 0.1.15

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 (71) hide show
  1. package/bridge/bridge.d.ts +20 -5
  2. package/bridge/bridge.js +29 -14
  3. package/bridge/config.js +6 -0
  4. package/bridge/gateway/gateway-config.d.ts +16 -5
  5. package/bridge/gateway/gateway-config.js +68 -12
  6. package/bridge/gateway/gateway-ws-client.d.ts +4 -1
  7. package/bridge/gateway/gateway-ws-client.js +41 -11
  8. package/bridge/gateway/poller.js +18 -8
  9. package/bridge/providers/emergency-commands.d.ts +9 -1
  10. package/bridge/providers/emergency-commands.js +38 -1
  11. package/bridge/providers/gateway.d.ts +51 -1
  12. package/bridge/providers/gateway.js +209 -22
  13. package/bridge/providers/onboarding-commands.d.ts +11 -0
  14. package/bridge/providers/onboarding-commands.js +5 -5
  15. package/bridge/providers/risk-calculator.d.ts +61 -2
  16. package/bridge/providers/risk-calculator.js +92 -20
  17. package/bridge/utils/skill-signing.js +8 -3
  18. package/ccxt/binance-public.d.ts +17 -5
  19. package/ccxt/binance-public.js +31 -3
  20. package/config/operator-provenance.d.ts +6 -0
  21. package/config/operator-provenance.js +50 -0
  22. package/config/plugin-config-io.d.ts +15 -1
  23. package/config/plugin-config-io.js +29 -0
  24. package/exchange-adapter.d.ts +13 -0
  25. package/index.js +230 -176
  26. package/ingest/event-loop-monitor.d.ts +22 -0
  27. package/ingest/event-loop-monitor.js +190 -0
  28. package/ingest/position-auto-capture.d.ts +5 -0
  29. package/ingest/position-auto-capture.js +14 -5
  30. package/ingest/readiness-reporter.d.ts +26 -6
  31. package/ingest/readiness-reporter.js +137 -9
  32. package/ingest/skill-version-reader.d.ts +16 -0
  33. package/ingest/skill-version-reader.js +64 -0
  34. package/live/approval-lifecycle.d.ts +30 -0
  35. package/live/approval-lifecycle.js +80 -0
  36. package/live/bracket-types.d.ts +9 -0
  37. package/live/live-adapter.d.ts +0 -1
  38. package/live/user-data-stream.js +10 -2
  39. package/onboarding/runtime.d.ts +34 -1
  40. package/onboarding/runtime.js +56 -5
  41. package/openclaw.plugin.json +1 -1
  42. package/package.json +6 -5
  43. package/risk/pre-trade-check.js +18 -5
  44. package/simulator/exchange-simulator.d.ts +45 -2
  45. package/simulator/exchange-simulator.js +96 -4
  46. package/simulator/types.d.ts +17 -0
  47. package/skills/reefclaw/SKILL.md +6 -11
  48. package/strategy/condition-registry.js +9 -2
  49. package/strategy/evaluator.d.ts +5 -0
  50. package/tools/attach-brackets.js +50 -1
  51. package/tools/cancel-all-orders.js +9 -1
  52. package/tools/create-order.js +18 -1
  53. package/tools/get-bracket-config.d.ts +21 -2
  54. package/tools/get-bracket-config.js +18 -2
  55. package/tools/set-trading-mode.js +6 -3
  56. package/venues/hyperliquid/hl-bracket-coordinator.d.ts +25 -1
  57. package/venues/hyperliquid/hl-bracket-coordinator.js +57 -0
  58. package/venues/hyperliquid/hl-brackets.d.ts +10 -0
  59. package/venues/hyperliquid/hl-brackets.js +45 -13
  60. package/venues/hyperliquid/hl-fill-ingest.d.ts +18 -0
  61. package/venues/hyperliquid/hl-fill-ingest.js +88 -0
  62. package/venues/hyperliquid/hl-live-adapter.d.ts +36 -0
  63. package/venues/hyperliquid/hl-live-adapter.js +116 -7
  64. package/venues/hyperliquid/hl-public.d.ts +12 -5
  65. package/venues/hyperliquid/hl-public.js +24 -3
  66. package/venues/hyperliquid/hl-user-stream.d.ts +13 -1
  67. package/venues/hyperliquid/hl-user-stream.js +4 -1
  68. package/venues/registry.js +8 -7
  69. package/wave9/paper-admission-guard.d.ts +12 -1
  70. package/wave9/paper-admission-guard.js +12 -1
  71. package/scripts/assemble.mjs +0 -130
@@ -43,11 +43,62 @@ export function applyRegimeAdjustment(baseLimits, regime) {
43
43
  };
44
44
  }
45
45
  // ---- Drawdown zone thresholds ----
46
+ /** Fallback zone boundaries, used only until the tenant's trading params load.
47
+ * The AUTHORITATIVE values are the operator's `drawdownYellow/Orange/Red`
48
+ * trading params — the same numbers the plugin's pre-trade gate rejects on.
49
+ * Keeping a second hardcoded ladder here is how the skill ended up
50
+ * auto-flattening at -2.5% on an account configured to -4%. */
46
51
  export const DRAWDOWN_ZONE_THRESHOLDS = {
47
52
  YELLOW: -0.01, // -1%
48
53
  ORANGE: -0.02, // -2%
49
54
  RED: -0.025, // -2.5%
50
55
  };
56
+ // ---- Hardcoded bounds on tenant-supplied risk params ----
57
+ //
58
+ // CLAUDE.md load-bearing rule: central config may move knobs only WITHIN
59
+ // hardcoded bounds — it must never be able to disable a safety mechanism.
60
+ // The RED threshold drives the auto-flatten, so without a floor a single
61
+ // careless dashboard edit (drawdownRed = -0.5) would silently switch the
62
+ // auto-flatten off. Out-of-band values are REJECTED (keep last-good), not
63
+ // clamped — we never invent a threshold the operator didn't set.
64
+ /** The auto-flatten trigger can never be configured looser than −10%. */
65
+ export const AUTO_FLATTEN_RED_FLOOR = -0.10;
66
+ /** Sanity bands for tenant limit fields. Values outside → field ignored,
67
+ * previous value kept. Wide on purpose: these reject nonsense, not policy. */
68
+ export const TENANT_LIMIT_BOUNDS = {
69
+ maxPositionSize: { min: 1, max: 1e9 },
70
+ maxOpenPositions: { min: 1, max: 100 },
71
+ maxGrossExposure: { min: 0.01, max: 20 },
72
+ maxPerTradeLoss: { min: 0.01, max: 1e7 }, // magnitude; sign applied by caller
73
+ };
74
+ /** Finite number within [min, max], else null. */
75
+ export function boundedNum(v, min, max) {
76
+ if (typeof v !== 'number' || !Number.isFinite(v))
77
+ return null;
78
+ return v >= min && v <= max ? v : null;
79
+ }
80
+ /**
81
+ * Validate a tenant drawdown-zone ladder. Returns null when acceptable, else
82
+ * a human-readable reason. Requirements: all finite; YELLOW ≤ 0 (zones are
83
+ * losses); strictly monotonic YELLOW > ORANGE > RED (a scrambled ladder would
84
+ * put the book straight into RED and auto-flatten it); RED no looser than
85
+ * AUTO_FLATTEN_RED_FLOOR.
86
+ */
87
+ export function validateDrawdownLadder(t) {
88
+ for (const [k, v] of Object.entries(t)) {
89
+ if (typeof v !== 'number' || !Number.isFinite(v))
90
+ return `${k} is not a finite number`;
91
+ }
92
+ if (t.YELLOW > 0)
93
+ return `YELLOW ${t.YELLOW} must be ≤ 0`;
94
+ if (!(t.YELLOW > t.ORANGE && t.ORANGE > t.RED)) {
95
+ return `not monotonic (need YELLOW > ORANGE > RED, got ${t.YELLOW} / ${t.ORANGE} / ${t.RED})`;
96
+ }
97
+ if (t.RED < AUTO_FLATTEN_RED_FLOOR) {
98
+ return `RED ${t.RED} is looser than the hardcoded auto-flatten floor ${AUTO_FLATTEN_RED_FLOOR}`;
99
+ }
100
+ return null;
101
+ }
51
102
  // ---- Rate tracking window ----
52
103
  const RATE_WINDOW_MS = 60_000; // 1 minute
53
104
  // ---- Volatility + drawdown pure functions ----
@@ -73,12 +124,12 @@ export function applyVolatilityAdjustment(baseLimits, volFactor) {
73
124
  };
74
125
  }
75
126
  /** Determine drawdown zone from drawdown ratio (e.g. -0.015 = -1.5%). */
76
- export function getDrawdownZone(drawdownRatio) {
77
- if (drawdownRatio <= DRAWDOWN_ZONE_THRESHOLDS.RED)
127
+ export function getDrawdownZone(drawdownRatio, thresholds = DRAWDOWN_ZONE_THRESHOLDS) {
128
+ if (drawdownRatio <= thresholds.RED)
78
129
  return 'RED';
79
- if (drawdownRatio <= DRAWDOWN_ZONE_THRESHOLDS.ORANGE)
130
+ if (drawdownRatio <= thresholds.ORANGE)
80
131
  return 'ORANGE';
81
- if (drawdownRatio <= DRAWDOWN_ZONE_THRESHOLDS.YELLOW)
132
+ if (drawdownRatio <= thresholds.YELLOW)
82
133
  return 'YELLOW';
83
134
  return 'GREEN';
84
135
  }
@@ -143,10 +194,22 @@ export function countRecentEvents(timestamps) {
143
194
  }
144
195
  /**
145
196
  * Detect risk limit breaches from current metrics.
197
+ *
198
+ * ★ CRITICAL is reserved for the limits that are ACTUALLY ENFORCED — the
199
+ * tenant's trading params, which the plugin's `preTradeRiskCheck` rejects
200
+ * orders against (`plugin/src/risk/pre-trade-check.ts`). A red "Gate fail" on
201
+ * the dashboard must mean "the agent is being blocked right now".
202
+ *
203
+ * `advisoryLimits` carries the vol/regime-TIGHTENED view. Those multipliers
204
+ * live only in this file — no enforcer applies them — so they may raise a
205
+ * WARNING (amber, indication) but must never produce a CRITICAL. Conflating
206
+ * the two is what put a permanent red "Gate fail · Gross exposure 121%" on a
207
+ * live book whose 1.5x enforced limit was never even approached.
146
208
  */
147
- export function detectBreaches(metrics, limits) {
209
+ export function detectBreaches(metrics, limits, advisoryLimits) {
148
210
  const breaches = [];
149
211
  const now = new Date().toISOString();
212
+ const advisory = advisoryLimits ?? limits;
150
213
  // Gross exposure
151
214
  if (metrics.grossExposure > limits.position.maxGrossExposure) {
152
215
  breaches.push({
@@ -158,10 +221,11 @@ export function detectBreaches(metrics, limits) {
158
221
  timestamp: now,
159
222
  });
160
223
  }
161
- else if (metrics.grossExposure > limits.position.maxGrossExposure * 0.8) {
224
+ else if (metrics.grossExposure > advisory.position.maxGrossExposure ||
225
+ metrics.grossExposure > limits.position.maxGrossExposure * 0.8) {
162
226
  breaches.push({
163
227
  metric: 'grossExposure',
164
- limit: limits.position.maxGrossExposure,
228
+ limit: Math.min(advisory.position.maxGrossExposure, limits.position.maxGrossExposure),
165
229
  current: metrics.grossExposure,
166
230
  level: 'WARNING',
167
231
  action: 'ALERT',
@@ -179,10 +243,11 @@ export function detectBreaches(metrics, limits) {
179
243
  timestamp: now,
180
244
  });
181
245
  }
182
- else if (Math.abs(metrics.netExposure) > limits.position.maxNetExposure * 0.8) {
246
+ else if (Math.abs(metrics.netExposure) > advisory.position.maxNetExposure ||
247
+ Math.abs(metrics.netExposure) > limits.position.maxNetExposure * 0.8) {
183
248
  breaches.push({
184
249
  metric: 'netExposure',
185
- limit: limits.position.maxNetExposure,
250
+ limit: Math.min(advisory.position.maxNetExposure, limits.position.maxNetExposure),
186
251
  current: metrics.netExposure,
187
252
  level: 'WARNING',
188
253
  action: 'ALERT',
@@ -270,8 +335,12 @@ export function computeRiskMetrics(state) {
270
335
  // Prune and count rate events (non-mutating)
271
336
  const orderResult = countRecentEvents(state.orderTimestamps);
272
337
  const cancelResult = countRecentEvents(state.cancelTimestamps);
338
+ // Enforced limits: the tenant's configured trading params when they've been
339
+ // fetched, else the fallback constants. These are what the plugin's
340
+ // pre-trade gate rejects on, so they — and only they — drive CRITICAL.
341
+ const enforcedLimits = state.baseLimits ?? DEFAULT_RISK_LIMITS;
273
342
  // Volatility adjustment (when ATR data is available)
274
- let adjustedLimits = DEFAULT_RISK_LIMITS;
343
+ let adjustedLimits = enforcedLimits;
275
344
  let effectiveLimits;
276
345
  let volatilityInfo;
277
346
  let volFactor = 1.0;
@@ -279,7 +348,7 @@ export function computeRiskMetrics(state) {
279
348
  volFactor = computeVolFactor(state.atrData);
280
349
  const adjusted = volFactor > 1.0;
281
350
  if (adjusted) {
282
- adjustedLimits = applyVolatilityAdjustment(DEFAULT_RISK_LIMITS, volFactor);
351
+ adjustedLimits = applyVolatilityAdjustment(enforcedLimits, volFactor);
283
352
  }
284
353
  volatilityInfo = {
285
354
  atr14: state.atrData.currentAtr,
@@ -305,14 +374,17 @@ export function computeRiskMetrics(state) {
305
374
  };
306
375
  }
307
376
  }
308
- if (adjustedLimits !== DEFAULT_RISK_LIMITS) {
377
+ if (adjustedLimits !== enforcedLimits) {
309
378
  effectiveLimits = adjustedLimits;
310
379
  }
311
- // Drawdown zone
312
- const drawdownZone = getDrawdownZone(dailyDrawdown);
380
+ // Drawdown zone — boundaries come from the tenant's trading params so the
381
+ // skill's RED-zone auto-flatten fires at the operator's configured level,
382
+ // not a second hardcoded ladder that can be stricter than the plugin's.
383
+ const zoneThresholds = state.drawdownThresholds ?? DRAWDOWN_ZONE_THRESHOLDS;
384
+ const drawdownZone = getDrawdownZone(dailyDrawdown, zoneThresholds);
313
385
  const drawdownZoneMessage = getDrawdownZoneMessage(drawdownZone, dailyDrawdown);
314
- // Detect breaches using effective limits (vol + regime adjusted) when available
315
- const limitsForBreaches = effectiveLimits ?? DEFAULT_RISK_LIMITS;
386
+ // Breaches: CRITICAL against the ENFORCED limits, WARNING against the
387
+ // vol/regime-tightened advisory view (see detectBreaches).
316
388
  const dailyLoss = state.realizedPnlToday ?? 0;
317
389
  const breaches = detectBreaches({
318
390
  grossExposure: grossRatio,
@@ -321,12 +393,12 @@ export function computeRiskMetrics(state) {
321
393
  dailyLoss,
322
394
  ordersPerMinute: orderResult.count,
323
395
  cancelsPerMinute: cancelResult.count,
324
- }, limitsForBreaches);
396
+ }, enforcedLimits, effectiveLimits);
325
397
  // Add zone-specific breaches
326
398
  if (drawdownZone === 'ORANGE' || drawdownZone === 'RED') {
327
399
  breaches.push({
328
400
  metric: 'drawdownZone',
329
- limit: drawdownZone === 'RED' ? DRAWDOWN_ZONE_THRESHOLDS.RED : DRAWDOWN_ZONE_THRESHOLDS.ORANGE,
401
+ limit: drawdownZone === 'RED' ? zoneThresholds.RED : zoneThresholds.ORANGE,
330
402
  current: dailyDrawdown,
331
403
  level: 'CRITICAL',
332
404
  action: drawdownZone === 'RED' ? 'AUTO_PAUSE' : 'REJECT_ORDER',
@@ -336,7 +408,7 @@ export function computeRiskMetrics(state) {
336
408
  else if (drawdownZone === 'YELLOW') {
337
409
  breaches.push({
338
410
  metric: 'drawdownZone',
339
- limit: DRAWDOWN_ZONE_THRESHOLDS.YELLOW,
411
+ limit: zoneThresholds.YELLOW,
340
412
  current: dailyDrawdown,
341
413
  level: 'WARNING',
342
414
  action: 'ALERT',
@@ -345,7 +417,7 @@ export function computeRiskMetrics(state) {
345
417
  }
346
418
  return {
347
419
  metrics: {
348
- limits: DEFAULT_RISK_LIMITS,
420
+ limits: enforcedLimits,
349
421
  utilization: {
350
422
  currentPositionSize: positionSizes,
351
423
  openPositionCount: state.positions.filter((p) => computePositionNotional(p, state.lastTickerPrice) !== 0).length,
@@ -14,9 +14,14 @@
14
14
  // (gitignored — keep it offline; NEVER on the relay or any server)
15
15
  // 4. rebuild + redeploy the skill (deploy-skillmd-ota.py is already wired to sign)
16
16
  //
17
- // Until a key is pinned, OTA is FAIL-CLOSED (every push rejected) while
18
- // SKILL_OTA_REQUIRE_SIGNATURE is on (the default). Normal trading is unaffected
19
- // — only the OTA-update path gates on this.
17
+ // ENFORCEMENT IS CURRENTLY DORMANT: SKILL_OTA_REQUIRE_SIGNATURE defaults OFF
18
+ // and no key is pinned (C1 is ON HOLD pending the agent-update-mechanism
19
+ // decision see signatureRequired() below). While dormant, OTA updates apply
20
+ // unsigned exactly as before this module existed; the authenticated webapp
21
+ // pull channel (rc_ token) remains the trust boundary. When the flag is turned
22
+ // ON: a pinned key verifies every update, and flag-on-without-a-key FAILS
23
+ // CLOSED (every push rejected). Normal trading is unaffected either way —
24
+ // only the OTA-update path gates on this.
20
25
  import { createHash, createPublicKey, verify as cryptoVerify } from 'node:crypto';
21
26
  import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
22
27
  import { join } from 'node:path';
@@ -1,3 +1,4 @@
1
+ import { type VenueReachabilityResult } from '@reefclaw/shared';
1
2
  import type { CcxtTicker, CcxtOHLCV } from '../types.js';
2
3
  import type { OrderBookDepth } from '../simulator/types.js';
3
4
  import type { PublicMarketDataApi } from './public-market-data-api.js';
@@ -28,9 +29,20 @@ export declare class BinancePublicApi implements PublicMarketDataApi {
28
29
  * - 'reachable' clean response (driftMs = serverTime − localTime)
29
30
  * - 'geo_blocked' HTTP 451 — host is in a restricted region (actionable)
30
31
  * - 'unknown' the ban/weight gate paused us — NOT a host problem
31
- * - 'unreachable' network / DNS / timeout / other error */
32
- probeReachability(): Promise<{
33
- outcome: 'reachable' | 'geo_blocked' | 'unreachable' | 'unknown';
34
- driftMs: number | null;
35
- }>;
32
+ * - 'stalled' THIS process was starved we learned nothing (issue #265)
33
+ * - 'unreachable' network / DNS / timeout / other error
34
+ *
35
+ * ★ Self-stall detection: an error is evidence about BINANCE only if it
36
+ * arrived on schedule. When the host starves the process the loop stops
37
+ * running, ccxt's own timeout lands minutes late, and the old code blamed the
38
+ * network — producing a confident "you are geo-blocked / check your firewall"
39
+ * banner while the agent was in fact trading normally. Past
40
+ * REACHABILITY_STALL_FACTOR× the request budget we report `stalled` and the
41
+ * reporter renders it `unknown`. A 451 still wins: the server ANSWERED, so
42
+ * that classification stands however late we noticed it. */
43
+ probeReachability(): Promise<VenueReachabilityResult>;
44
+ /** The request budget the stall yardstick is measured against. ccxt owns the
45
+ * actual timeout, so read it from the instance rather than hardcoding a
46
+ * second copy that could silently drift from it. */
47
+ private probeTimeoutMs;
36
48
  }
@@ -33,6 +33,7 @@
33
33
  import { createRequire } from 'node:module';
34
34
  import { logger } from '../logger.js';
35
35
  import { assertNotBanned, noteBinanceError, noteSuccess, BinanceBannedError } from './binance-ban-gate.js';
36
+ import { REACHABILITY_STALL_FACTOR } from '@reefclaw/shared';
36
37
  const TAG = 'binance-public';
37
38
  // Load ccxt via CJS require — OpenClaw's ESM loader gives wrong module shape
38
39
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -190,8 +191,19 @@ export class BinancePublicApi {
190
191
  * - 'reachable' clean response (driftMs = serverTime − localTime)
191
192
  * - 'geo_blocked' HTTP 451 — host is in a restricted region (actionable)
192
193
  * - 'unknown' the ban/weight gate paused us — NOT a host problem
193
- * - 'unreachable' network / DNS / timeout / other error */
194
+ * - 'stalled' THIS process was starved we learned nothing (issue #265)
195
+ * - 'unreachable' network / DNS / timeout / other error
196
+ *
197
+ * ★ Self-stall detection: an error is evidence about BINANCE only if it
198
+ * arrived on schedule. When the host starves the process the loop stops
199
+ * running, ccxt's own timeout lands minutes late, and the old code blamed the
200
+ * network — producing a confident "you are geo-blocked / check your firewall"
201
+ * banner while the agent was in fact trading normally. Past
202
+ * REACHABILITY_STALL_FACTOR× the request budget we report `stalled` and the
203
+ * reporter renders it `unknown`. A 451 still wins: the server ANSWERED, so
204
+ * that classification stands however late we noticed it. */
194
205
  async probeReachability() {
206
+ const startedAt = Date.now();
195
207
  try {
196
208
  assertNotBanned('reachabilityProbe');
197
209
  const r = await this.exchange.fapiPublicGetTime({});
@@ -209,8 +221,24 @@ export class BinancePublicApi {
209
221
  }
210
222
  const msg = err instanceof Error ? err.message : String(err);
211
223
  const geo = msg.includes('451');
212
- logger.warn(TAG, `probeReachability failed${geo ? ' (HTTP 451 geo-block)' : ''}: ${msg}`);
213
- return { outcome: geo ? 'geo_blocked' : 'unreachable', driftMs: null };
224
+ if (geo) {
225
+ logger.warn(TAG, `probeReachability failed (HTTP 451 geo-block): ${msg}`);
226
+ return { outcome: 'geo_blocked', driftMs: null };
227
+ }
228
+ const elapsedMs = Date.now() - startedAt;
229
+ if (elapsedMs > this.probeTimeoutMs() * REACHABILITY_STALL_FACTOR) {
230
+ logger.warn(TAG, `probeReachability inconclusive: this process was starved — a ${this.probeTimeoutMs()}ms probe took ${Math.round(elapsedMs / 1000)}s (${msg}). Reporting reachability as unknown, NOT as a Binance failure.`);
231
+ return { outcome: 'stalled', driftMs: null, stallMs: elapsedMs };
232
+ }
233
+ logger.warn(TAG, `probeReachability failed: ${msg}`);
234
+ return { outcome: 'unreachable', driftMs: null };
214
235
  }
215
236
  }
237
+ /** The request budget the stall yardstick is measured against. ccxt owns the
238
+ * actual timeout, so read it from the instance rather than hardcoding a
239
+ * second copy that could silently drift from it. */
240
+ probeTimeoutMs() {
241
+ const t = Number(this.exchange?.timeout);
242
+ return Number.isFinite(t) && t > 0 ? t : 10_000;
243
+ }
216
244
  }
@@ -0,0 +1,6 @@
1
+ export interface ProvenanceVerdict {
2
+ ok: boolean;
3
+ /** Agent-legible refusal — returned verbatim as the tool error. */
4
+ error?: string;
5
+ }
6
+ export declare function verifyOperatorProvenance(provided: unknown, configPath?: string): ProvenanceVerdict;
@@ -0,0 +1,50 @@
1
+ // Operator-provenance gate for the state-mutating operator tools
2
+ // (audit 2026-07-26 F12).
3
+ //
4
+ // "Operator-only. Never called by the agent." was PROSE in four tool
5
+ // descriptions — nothing enforced it, and `acknowledged:true` is
6
+ // caller-supplied, so a tool-capable agent could walk the PAPER→LIVE ladder,
7
+ // clear the exchange credentials, or disable the stop-loss requirement with
8
+ // one impulsive call. The dashboard path (browser → relay → skill bridge →
9
+ // gateway) now proves provenance: the bridge injects the rc_* connection
10
+ // token it already authenticates with, and the plugin compares it against
11
+ // its own copy. The chat redaction guards keep rc_* tokens OUT of the
12
+ // agent's context, so the agent cannot produce one conversationally.
13
+ //
14
+ // THREAT-MODEL HONESTY: the agent runs on the operator's own box and could
15
+ // read plugin-config with exec tools — this gate defends against one-call
16
+ // LLM impulsivity (the realistic failure mode), not a deliberately
17
+ // adversarial agent, which owns the box and could edit the config directly.
18
+ //
19
+ // Fail direction: OPEN when no connection token is configured (a pure-local
20
+ // dev box has no dashboard to inject provenance — same
21
+ // infrastructure-absent ⇒ allow direction as the entitlement gate);
22
+ // CLOSED on a missing or mismatched token when one IS configured.
23
+ // Read per call (no restart needed after a token rotation — F10 philosophy).
24
+ import { timingSafeEqual } from 'node:crypto';
25
+ import { readPluginConfig } from './plugin-config-io.js';
26
+ import { resolveIngestToken } from './user-data-stream-config.js';
27
+ const REFUSAL = 'operator_only: this tool changes safety-critical operator settings and accepts requests ' +
28
+ 'ONLY from the ReefClaw dashboard (operator provenance token missing or invalid). Do not ' +
29
+ 'retry and do not attempt to obtain the token — if this change is wanted, the OPERATOR ' +
30
+ 'makes it from the dashboard Settings panel.';
31
+ export function verifyOperatorProvenance(provided, configPath) {
32
+ let expected;
33
+ try {
34
+ expected = resolveIngestToken(readPluginConfig(configPath))?.trim() || undefined;
35
+ }
36
+ catch {
37
+ expected = undefined;
38
+ }
39
+ if (!expected)
40
+ return { ok: true }; // unconfigured box — nothing to prove against
41
+ const given = typeof provided === 'string' ? provided.trim() : '';
42
+ if (given.length === 0)
43
+ return { ok: false, error: REFUSAL };
44
+ const a = Buffer.from(given, 'utf8');
45
+ const b = Buffer.from(expected, 'utf8');
46
+ if (a.length !== b.length || !timingSafeEqual(a, b)) {
47
+ return { ok: false, error: REFUSAL };
48
+ }
49
+ return { ok: true };
50
+ }
@@ -46,8 +46,12 @@ export interface PluginConfigFile {
46
46
  hl?: {
47
47
  marketSlippagePct?: number;
48
48
  };
49
+ /** MICRO_LIVE per-order notional cap in quote-USD (USDT on Binance, USDC on
50
+ * Hyperliquid). Default 50 when the mode is MICRO_LIVE and no value is set.
51
+ * `sizeCapPercent` used to be accepted here but was NEVER enforced by any
52
+ * adapter (dead config implying protection it didn't provide) — it is no
53
+ * longer read; loadMicroLiveConfig warns when it is present. */
49
54
  microLive?: {
50
- sizeCapPercent?: number;
51
55
  maxPositionUSDT?: number;
52
56
  };
53
57
  /** Exchange-native bracket orders (STOP_MARKET + TAKE_PROFIT_MARKET).
@@ -202,6 +206,16 @@ export declare function defaultConfigPath(): string;
202
206
  * the override to the default (same read-at-build-time pattern as
203
207
  * loadBracketMode). */
204
208
  export declare function loadStopWatcherIntervalMs(path?: string): number | undefined;
209
+ /** Best-effort read of the operator's micro-live notional cap
210
+ * (`microLive.maxPositionUSDT`). Read at every adapter CONSTRUCTION — boot
211
+ * AND runtime reconnects — so a mode flip or credential save can't silently
212
+ * reset a raised OR lowered cap back to the $50 default (audit 2026-07-26
213
+ * F8; same read-at-build-time pattern as loadBracketMode /
214
+ * loadStopWatcherIntervalMs). Returns undefined when unset/garbage — the
215
+ * adapters then apply their own MICRO_LIVE default. */
216
+ export declare function loadMicroLiveConfig(path?: string): {
217
+ maxPositionUSDT?: number;
218
+ } | undefined;
205
219
  /** Read the config file. Returns `{}` if the file doesn't exist.
206
220
  * Throws if the file exists but is unreadable or not valid JSON — callers
207
221
  * should treat that as an abort signal, not silently overwrite. */
@@ -5,9 +5,15 @@
5
5
  // sets credentials or switches trading mode from the webapp. Writes must be
6
6
  // crash-safe (no half-written JSON) and must preserve keys we don't understand
7
7
  // (forwards compatibility — other tools may add fields we don't know about).
8
+ //
9
+ // Write authorization: every mutation path into this module is an
10
+ // operator-only tool gated on dashboard provenance (verifyOperatorProvenance,
11
+ // audit F12) — the agent cannot reach these writes conversationally. The file
12
+ // is the plugin's OWN config store; nothing here touches OpenClaw's config.
8
13
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, } from 'node:fs';
9
14
  import { homedir } from 'node:os';
10
15
  import { dirname, join } from 'node:path';
16
+ import { logger } from '../logger.js';
11
17
  /** The connection the AGENT saves during onboarding lives in OpenClaw's own
12
18
  * config (`skills.entries.reefclaw.config` in ~/.openclaw/openclaw.json) —
13
19
  * the connector reads it there, and since the chat-install flow never writes
@@ -53,6 +59,29 @@ export function loadStopWatcherIntervalMs(path) {
53
59
  catch { /* best-effort — default applies */ }
54
60
  return undefined;
55
61
  }
62
+ /** Best-effort read of the operator's micro-live notional cap
63
+ * (`microLive.maxPositionUSDT`). Read at every adapter CONSTRUCTION — boot
64
+ * AND runtime reconnects — so a mode flip or credential save can't silently
65
+ * reset a raised OR lowered cap back to the $50 default (audit 2026-07-26
66
+ * F8; same read-at-build-time pattern as loadBracketMode /
67
+ * loadStopWatcherIntervalMs). Returns undefined when unset/garbage — the
68
+ * adapters then apply their own MICRO_LIVE default. */
69
+ export function loadMicroLiveConfig(path) {
70
+ try {
71
+ const ml = readPluginConfig(path).microLive;
72
+ if (!ml || typeof ml !== 'object')
73
+ return undefined;
74
+ if (ml.sizeCapPercent !== undefined) {
75
+ logger.warn('plugin-config', 'microLive.sizeCapPercent is set but has NEVER been enforced by any adapter — it has no effect. ' +
76
+ 'Remove it; microLive.maxPositionUSDT is the enforced per-order notional cap.');
77
+ }
78
+ const cap = ml.maxPositionUSDT;
79
+ if (typeof cap === 'number' && Number.isFinite(cap) && cap > 0)
80
+ return { maxPositionUSDT: cap };
81
+ }
82
+ catch { /* best-effort — adapter default applies */ }
83
+ return undefined;
84
+ }
56
85
  /** Read the config file. Returns `{}` if the file doesn't exist.
57
86
  * Throws if the file exists but is unreadable or not valid JSON — callers
58
87
  * should treat that as an abort signal, not silently overwrite. */
@@ -28,6 +28,19 @@ export interface OrderOptions {
28
28
  * Only create_order is blocked when readiness !== 'READY'.
29
29
  */
30
30
  export interface IExchangeAdapter {
31
+ /**
32
+ * ★ Venue capability: this adapter's exchange-side protective legs ARE the
33
+ * safety floor — there is no watcher fallback and no `brackets.mode=off`
34
+ * arm, so brackets are attached unconditionally (Hyperliquid live).
35
+ *
36
+ * Read it wherever `brackets.mode` is consulted. That flag is a
37
+ * BINANCE-ONLY lifecycle knob (it is only ever passed to `LiveAdapter`), so
38
+ * treating it as the global answer made an HL rig report "Brackets off" on
39
+ * the dashboard AND — worse — skipped the mandatory-stop pre-trade gate,
40
+ * because that gate was wired behind `bracketsEnabled(loadBracketMode())`.
41
+ * Omitted/false on Binance + paper: byte-identical to the old behaviour.
42
+ */
43
+ readonly bracketsAlwaysEnforced?: boolean;
31
44
  createOrder(symbol: string, side: 'buy' | 'sell', type: 'market' | 'limit', amount: number, price?: number, metadata?: PositionMetadata, options?: OrderOptions): Promise<CcxtOrder>;
32
45
  cancelOrder(orderId: string, symbol?: string): Promise<CcxtOrder>;
33
46
  cancelAllOrders(symbol?: string): Promise<CcxtOrder[]>;