@reefclaw/openclaw-plugin 0.1.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 (371) hide show
  1. package/.gitignore +8 -0
  2. package/audit/mode-transition-audit.d.ts +11 -0
  3. package/audit/mode-transition-audit.js +29 -0
  4. package/balance-utils.d.ts +36 -0
  5. package/balance-utils.js +98 -0
  6. package/bridge/bridge.d.ts +109 -0
  7. package/bridge/bridge.js +1036 -0
  8. package/bridge/config.d.ts +43 -0
  9. package/bridge/config.js +176 -0
  10. package/bridge/connector.d.ts +52 -0
  11. package/bridge/connector.js +293 -0
  12. package/bridge/event-replay-buffer.d.ts +43 -0
  13. package/bridge/event-replay-buffer.js +109 -0
  14. package/bridge/gateway/event-parser.d.ts +209 -0
  15. package/bridge/gateway/event-parser.js +793 -0
  16. package/bridge/gateway/gateway-config.d.ts +39 -0
  17. package/bridge/gateway/gateway-config.js +86 -0
  18. package/bridge/gateway/gateway-http-client.d.ts +50 -0
  19. package/bridge/gateway/gateway-http-client.js +165 -0
  20. package/bridge/gateway/gateway-ws-client.d.ts +116 -0
  21. package/bridge/gateway/gateway-ws-client.js +417 -0
  22. package/bridge/gateway/poller.d.ts +146 -0
  23. package/bridge/gateway/poller.js +505 -0
  24. package/bridge/gateway/tool-discovery.d.ts +25 -0
  25. package/bridge/gateway/tool-discovery.js +198 -0
  26. package/bridge/index.d.ts +2 -0
  27. package/bridge/index.js +253 -0
  28. package/bridge/logger.d.ts +2 -0
  29. package/bridge/logger.js +2 -0
  30. package/bridge/provider.d.ts +156 -0
  31. package/bridge/provider.js +2 -0
  32. package/bridge/providers/emergency-commands.d.ts +54 -0
  33. package/bridge/providers/emergency-commands.js +235 -0
  34. package/bridge/providers/gateway.d.ts +322 -0
  35. package/bridge/providers/gateway.js +2302 -0
  36. package/bridge/providers/mock.d.ts +37 -0
  37. package/bridge/providers/mock.js +385 -0
  38. package/bridge/providers/onboarding-commands.d.ts +83 -0
  39. package/bridge/providers/onboarding-commands.js +213 -0
  40. package/bridge/providers/risk-calculator.d.ts +96 -0
  41. package/bridge/providers/risk-calculator.js +369 -0
  42. package/bridge/setup.d.ts +32 -0
  43. package/bridge/setup.js +226 -0
  44. package/bridge/types.d.ts +584 -0
  45. package/bridge/types.js +50 -0
  46. package/bridge/utils/reconnect.d.ts +6 -0
  47. package/bridge/utils/reconnect.js +6 -0
  48. package/bridge/utils/skill-signing.d.ts +51 -0
  49. package/bridge/utils/skill-signing.js +138 -0
  50. package/bridge/utils/skill-version.d.ts +17 -0
  51. package/bridge/utils/skill-version.js +74 -0
  52. package/ccxt/binance-ban-gate.d.ts +47 -0
  53. package/ccxt/binance-ban-gate.js +409 -0
  54. package/ccxt/binance-private.d.ts +325 -0
  55. package/ccxt/binance-private.js +1415 -0
  56. package/ccxt/binance-public.d.ts +18 -0
  57. package/ccxt/binance-public.js +147 -0
  58. package/config/agent-config-client.d.ts +55 -0
  59. package/config/agent-config-client.js +145 -0
  60. package/config/agent-config-poller.d.ts +25 -0
  61. package/config/agent-config-poller.js +100 -0
  62. package/config/brackets-config.d.ts +22 -0
  63. package/config/brackets-config.js +58 -0
  64. package/config/gate-store.d.ts +18 -0
  65. package/config/gate-store.js +61 -0
  66. package/config/plugin-config-io.d.ts +181 -0
  67. package/config/plugin-config-io.js +84 -0
  68. package/config/position-review-config.d.ts +35 -0
  69. package/config/position-review-config.js +105 -0
  70. package/config/tool-gate.d.ts +53 -0
  71. package/config/tool-gate.js +125 -0
  72. package/config/user-data-stream-config.d.ts +85 -0
  73. package/config/user-data-stream-config.js +224 -0
  74. package/connector-supervisor.d.ts +36 -0
  75. package/connector-supervisor.js +149 -0
  76. package/exchange-adapter.d.ts +49 -0
  77. package/exchange-adapter.js +4 -0
  78. package/index.d.ts +30 -0
  79. package/index.js +2030 -0
  80. package/ingest/pending-entry-metadata.d.ts +52 -0
  81. package/ingest/pending-entry-metadata.js +182 -0
  82. package/ingest/position-auto-capture.d.ts +98 -0
  83. package/ingest/position-auto-capture.js +394 -0
  84. package/ingest/position-decisions-client.d.ts +318 -0
  85. package/ingest/position-decisions-client.js +296 -0
  86. package/ingest/reconcile-db-vs-exchange.d.ts +13 -0
  87. package/ingest/reconcile-db-vs-exchange.js +114 -0
  88. package/ingest/reconciler-cleanup.d.ts +37 -0
  89. package/ingest/reconciler-cleanup.js +147 -0
  90. package/ingest/rest-gap-filler.d.ts +191 -0
  91. package/ingest/rest-gap-filler.js +565 -0
  92. package/ingest/touched-symbols-store.d.ts +25 -0
  93. package/ingest/touched-symbols-store.js +96 -0
  94. package/ingest/trade-store-client.d.ts +40 -0
  95. package/ingest/trade-store-client.js +125 -0
  96. package/ingest/ws-ingest.d.ts +43 -0
  97. package/ingest/ws-ingest.js +126 -0
  98. package/learning/setup-family.d.ts +21 -0
  99. package/learning/setup-family.js +103 -0
  100. package/lifecycle/install-signal-handlers.d.ts +33 -0
  101. package/lifecycle/install-signal-handlers.js +112 -0
  102. package/lifecycle/shutdown-coordinator.d.ts +43 -0
  103. package/lifecycle/shutdown-coordinator.js +131 -0
  104. package/live/bracket-id.d.ts +18 -0
  105. package/live/bracket-id.js +81 -0
  106. package/live/bracket-ledger.d.ts +54 -0
  107. package/live/bracket-ledger.js +267 -0
  108. package/live/bracket-manager.d.ts +82 -0
  109. package/live/bracket-manager.js +478 -0
  110. package/live/bracket-params.d.ts +22 -0
  111. package/live/bracket-params.js +124 -0
  112. package/live/bracket-reconciler.d.ts +95 -0
  113. package/live/bracket-reconciler.js +573 -0
  114. package/live/bracket-types.d.ts +102 -0
  115. package/live/bracket-types.js +8 -0
  116. package/live/deposit-tracker.d.ts +62 -0
  117. package/live/deposit-tracker.js +97 -0
  118. package/live/emergency-controls.d.ts +32 -0
  119. package/live/emergency-controls.js +226 -0
  120. package/live/exchange-errors.d.ts +12 -0
  121. package/live/exchange-errors.js +130 -0
  122. package/live/exchange-info-cache.d.ts +35 -0
  123. package/live/exchange-info-cache.js +119 -0
  124. package/live/fact-subscriber.d.ts +78 -0
  125. package/live/fact-subscriber.js +182 -0
  126. package/live/intent-journal.d.ts +42 -0
  127. package/live/intent-journal.js +122 -0
  128. package/live/listen-key-manager.d.ts +70 -0
  129. package/live/listen-key-manager.js +169 -0
  130. package/live/live-adapter.d.ts +264 -0
  131. package/live/live-adapter.js +1665 -0
  132. package/live/live-balance-enricher.d.ts +32 -0
  133. package/live/live-balance-enricher.js +104 -0
  134. package/live/live-bracket-api.d.ts +13 -0
  135. package/live/live-bracket-api.js +20 -0
  136. package/live/live-state-store.d.ts +194 -0
  137. package/live/live-state-store.js +450 -0
  138. package/live/local-signal-service.d.ts +57 -0
  139. package/live/local-signal-service.js +146 -0
  140. package/live/local-strategy-evaluator.d.ts +62 -0
  141. package/live/local-strategy-evaluator.js +127 -0
  142. package/live/microstructure-assembler.d.ts +54 -0
  143. package/live/microstructure-assembler.js +148 -0
  144. package/live/order-poller.d.ts +29 -0
  145. package/live/order-poller.js +125 -0
  146. package/live/position-state-store.d.ts +83 -0
  147. package/live/position-state-store.js +237 -0
  148. package/live/proposal-decision-listener.d.ts +64 -0
  149. package/live/proposal-decision-listener.js +288 -0
  150. package/live/proposal-manager.d.ts +76 -0
  151. package/live/proposal-manager.js +140 -0
  152. package/live/rate-limiter.d.ts +47 -0
  153. package/live/rate-limiter.js +159 -0
  154. package/live/reconciler.d.ts +39 -0
  155. package/live/reconciler.js +175 -0
  156. package/live/setup-buckets.d.ts +7 -0
  157. package/live/setup-buckets.js +33 -0
  158. package/live/slippage-tracker.d.ts +45 -0
  159. package/live/slippage-tracker.js +78 -0
  160. package/live/stop-watcher.d.ts +34 -0
  161. package/live/stop-watcher.js +158 -0
  162. package/live/user-data-active-probe.d.ts +54 -0
  163. package/live/user-data-active-probe.js +180 -0
  164. package/live/user-data-stream-controller.d.ts +200 -0
  165. package/live/user-data-stream-controller.js +579 -0
  166. package/live/user-data-stream-ws.d.ts +22 -0
  167. package/live/user-data-stream-ws.js +63 -0
  168. package/live/user-data-stream.d.ts +243 -0
  169. package/live/user-data-stream.js +704 -0
  170. package/logger.d.ts +2 -0
  171. package/logger.js +2 -0
  172. package/mfe.d.ts +21 -0
  173. package/mfe.js +68 -0
  174. package/onboarding/mode-ladder.d.ts +1 -0
  175. package/onboarding/mode-ladder.js +3 -0
  176. package/onboarding/runtime.d.ts +71 -0
  177. package/onboarding/runtime.js +153 -0
  178. package/openclaw.plugin.json +94 -0
  179. package/package.json +27 -0
  180. package/paper-adapter.d.ts +24 -0
  181. package/paper-adapter.js +91 -0
  182. package/persistence/state-manager.d.ts +42 -0
  183. package/persistence/state-manager.js +164 -0
  184. package/pinned-plan.d.ts +9 -0
  185. package/pinned-plan.js +23 -0
  186. package/risk/pre-trade-check.d.ts +38 -0
  187. package/risk/pre-trade-check.js +345 -0
  188. package/risk/pre-trade-types.d.ts +60 -0
  189. package/risk/pre-trade-types.js +3 -0
  190. package/scripts/assemble.mjs +114 -0
  191. package/shadow/shadow-tracker.d.ts +36 -0
  192. package/shadow/shadow-tracker.js +151 -0
  193. package/shadow/types.d.ts +42 -0
  194. package/shadow/types.js +20 -0
  195. package/shared/indicators-extended.d.ts +52 -0
  196. package/shared/indicators-extended.js +291 -0
  197. package/shared/indicators.d.ts +15 -0
  198. package/shared/indicators.js +114 -0
  199. package/signals/conditions/registry.d.ts +16 -0
  200. package/signals/conditions/registry.js +1274 -0
  201. package/signals/conditions/types.d.ts +1 -0
  202. package/signals/conditions/types.js +4 -0
  203. package/signals/direction-rules.d.ts +3 -0
  204. package/signals/direction-rules.js +24 -0
  205. package/signals/entry-rules.d.ts +6 -0
  206. package/signals/entry-rules.js +33 -0
  207. package/signals/serialize-context.d.ts +4 -0
  208. package/signals/serialize-context.js +39 -0
  209. package/signals/stop-rules.d.ts +3 -0
  210. package/signals/stop-rules.js +48 -0
  211. package/signals/strategy-adapter.d.ts +14 -0
  212. package/signals/strategy-adapter.js +122 -0
  213. package/signals/types.d.ts +1 -0
  214. package/signals/types.js +8 -0
  215. package/simulator/exchange-simulator.d.ts +93 -0
  216. package/simulator/exchange-simulator.js +684 -0
  217. package/simulator/fill-engine.d.ts +53 -0
  218. package/simulator/fill-engine.js +276 -0
  219. package/simulator/paper-market-feed.d.ts +26 -0
  220. package/simulator/paper-market-feed.js +104 -0
  221. package/simulator/realistic-fills.d.ts +59 -0
  222. package/simulator/realistic-fills.js +175 -0
  223. package/simulator/types.d.ts +219 -0
  224. package/simulator/types.js +43 -0
  225. package/skills/reefclaw/SKILL.md +97 -0
  226. package/strategy/builtin-strategies.d.ts +2 -0
  227. package/strategy/builtin-strategies.js +109 -0
  228. package/strategy/condition-registry.d.ts +3 -0
  229. package/strategy/condition-registry.js +153 -0
  230. package/strategy/evaluator.d.ts +67 -0
  231. package/strategy/evaluator.js +93 -0
  232. package/tools/assessment-validation.d.ts +118 -0
  233. package/tools/assessment-validation.js +415 -0
  234. package/tools/attach-brackets.d.ts +34 -0
  235. package/tools/attach-brackets.js +363 -0
  236. package/tools/audit-bracket-protection.d.ts +49 -0
  237. package/tools/audit-bracket-protection.js +527 -0
  238. package/tools/cancel-all-orders.d.ts +7 -0
  239. package/tools/cancel-all-orders.js +5 -0
  240. package/tools/cancel-order.d.ts +10 -0
  241. package/tools/cancel-order.js +14 -0
  242. package/tools/check-position-health.d.ts +46 -0
  243. package/tools/check-position-health.js +194 -0
  244. package/tools/clear-exchange-credentials.d.ts +24 -0
  245. package/tools/clear-exchange-credentials.js +70 -0
  246. package/tools/close-position.d.ts +22 -0
  247. package/tools/close-position.js +449 -0
  248. package/tools/create-order.d.ts +54 -0
  249. package/tools/create-order.js +338 -0
  250. package/tools/exit-gate.d.ts +58 -0
  251. package/tools/exit-gate.js +162 -0
  252. package/tools/fetch-balance.d.ts +5 -0
  253. package/tools/fetch-balance.js +4 -0
  254. package/tools/fetch-ohlcv.d.ts +11 -0
  255. package/tools/fetch-ohlcv.js +8 -0
  256. package/tools/fetch-open-orders.d.ts +7 -0
  257. package/tools/fetch-open-orders.js +4 -0
  258. package/tools/fetch-positions.d.ts +7 -0
  259. package/tools/fetch-positions.js +4 -0
  260. package/tools/fetch-ticker.d.ts +11 -0
  261. package/tools/fetch-ticker.js +5 -0
  262. package/tools/get-agent-profile.d.ts +4 -0
  263. package/tools/get-agent-profile.js +6 -0
  264. package/tools/get-analytics.d.ts +6 -0
  265. package/tools/get-analytics.js +7 -0
  266. package/tools/get-backtest.d.ts +12 -0
  267. package/tools/get-backtest.js +91 -0
  268. package/tools/get-basis.d.ts +7 -0
  269. package/tools/get-basis.js +7 -0
  270. package/tools/get-bracket-config.d.ts +11 -0
  271. package/tools/get-bracket-config.js +24 -0
  272. package/tools/get-cascade-risk.d.ts +7 -0
  273. package/tools/get-cascade-risk.js +8 -0
  274. package/tools/get-crypto-metrics.d.ts +18 -0
  275. package/tools/get-crypto-metrics.js +45 -0
  276. package/tools/get-cvd.d.ts +6 -0
  277. package/tools/get-cvd.js +6 -0
  278. package/tools/get-divergences.d.ts +6 -0
  279. package/tools/get-divergences.js +6 -0
  280. package/tools/get-funding-context.d.ts +6 -0
  281. package/tools/get-funding-context.js +16 -0
  282. package/tools/get-liquidation-levels.d.ts +7 -0
  283. package/tools/get-liquidation-levels.js +7 -0
  284. package/tools/get-liquidation-pulse.d.ts +9 -0
  285. package/tools/get-liquidation-pulse.js +22 -0
  286. package/tools/get-market-breadth.d.ts +6 -0
  287. package/tools/get-market-breadth.js +8 -0
  288. package/tools/get-market-intel.d.ts +19 -0
  289. package/tools/get-market-intel.js +116 -0
  290. package/tools/get-market-structure.d.ts +47 -0
  291. package/tools/get-market-structure.js +198 -0
  292. package/tools/get-my-mined-patterns.d.ts +20 -0
  293. package/tools/get-my-mined-patterns.js +61 -0
  294. package/tools/get-my-proposed-learnings.d.ts +20 -0
  295. package/tools/get-my-proposed-learnings.js +55 -0
  296. package/tools/get-my-recent-reviews.d.ts +22 -0
  297. package/tools/get-my-recent-reviews.js +66 -0
  298. package/tools/get-orderbook.d.ts +21 -0
  299. package/tools/get-orderbook.js +32 -0
  300. package/tools/get-pattern-scan.d.ts +7 -0
  301. package/tools/get-pattern-scan.js +8 -0
  302. package/tools/get-regime.d.ts +6 -0
  303. package/tools/get-regime.js +7 -0
  304. package/tools/get-relevant-learnings.d.ts +21 -0
  305. package/tools/get-relevant-learnings.js +65 -0
  306. package/tools/get-resting-liquidity.d.ts +6 -0
  307. package/tools/get-resting-liquidity.js +11 -0
  308. package/tools/get-risk-scenario.d.ts +29 -0
  309. package/tools/get-risk-scenario.js +47 -0
  310. package/tools/get-risk-summary.d.ts +51 -0
  311. package/tools/get-risk-summary.js +118 -0
  312. package/tools/get-sentiment.d.ts +4 -0
  313. package/tools/get-sentiment.js +6 -0
  314. package/tools/get-session-review.d.ts +7 -0
  315. package/tools/get-session-review.js +8 -0
  316. package/tools/get-setup-detail.d.ts +7 -0
  317. package/tools/get-setup-detail.js +303 -0
  318. package/tools/get-signals.d.ts +15 -0
  319. package/tools/get-signals.js +54 -0
  320. package/tools/get-sizing.d.ts +6 -0
  321. package/tools/get-sizing.js +6 -0
  322. package/tools/get-trade-feedback.d.ts +7 -0
  323. package/tools/get-trade-feedback.js +8 -0
  324. package/tools/get-trade-flow.d.ts +7 -0
  325. package/tools/get-trade-flow.js +7 -0
  326. package/tools/get-volume-analysis.d.ts +21 -0
  327. package/tools/get-volume-analysis.js +74 -0
  328. package/tools/get-volume-profile.d.ts +7 -0
  329. package/tools/get-volume-profile.js +7 -0
  330. package/tools/helpers.d.ts +25 -0
  331. package/tools/helpers.js +38 -0
  332. package/tools/intel-api.d.ts +14 -0
  333. package/tools/intel-api.js +52 -0
  334. package/tools/intel-cache.d.ts +25 -0
  335. package/tools/intel-cache.js +133 -0
  336. package/tools/list-strategies.d.ts +7 -0
  337. package/tools/list-strategies.js +6 -0
  338. package/tools/modify-stop.d.ts +17 -0
  339. package/tools/modify-stop.js +81 -0
  340. package/tools/modify-target.d.ts +17 -0
  341. package/tools/modify-target.js +71 -0
  342. package/tools/propose-learning.d.ts +22 -0
  343. package/tools/propose-learning.js +65 -0
  344. package/tools/query-review-outcomes.d.ts +30 -0
  345. package/tools/query-review-outcomes.js +64 -0
  346. package/tools/query-trades.d.ts +21 -0
  347. package/tools/query-trades.js +37 -0
  348. package/tools/record-position-reviews.d.ts +38 -0
  349. package/tools/record-position-reviews.js +147 -0
  350. package/tools/save-strategy.d.ts +16 -0
  351. package/tools/save-strategy.js +46 -0
  352. package/tools/scan-pairs.d.ts +18 -0
  353. package/tools/scan-pairs.js +220 -0
  354. package/tools/score-setup.d.ts +31 -0
  355. package/tools/score-setup.js +268 -0
  356. package/tools/set-bracket-requirement.d.ts +18 -0
  357. package/tools/set-bracket-requirement.js +81 -0
  358. package/tools/set-exchange-credentials.d.ts +25 -0
  359. package/tools/set-exchange-credentials.js +80 -0
  360. package/tools/set-trading-mode.d.ts +26 -0
  361. package/tools/set-trading-mode.js +135 -0
  362. package/tools/test-exchange-credentials.d.ts +16 -0
  363. package/tools/test-exchange-credentials.js +100 -0
  364. package/tools/toggle-strategy.d.ts +8 -0
  365. package/tools/toggle-strategy.js +8 -0
  366. package/trading-params-cache.d.ts +26 -0
  367. package/trading-params-cache.js +52 -0
  368. package/types.d.ts +110 -0
  369. package/types.js +7 -0
  370. package/util/plugin-paths.d.ts +3 -0
  371. package/util/plugin-paths.js +15 -0
@@ -0,0 +1,1036 @@
1
+ // Bridge: wires an OpenClawProvider to a Connector.
2
+ // Provider events → EventFrames with seq tracking → Connector → Relay → Browser.
3
+ // Incoming requests from Connector → routed to Provider methods.
4
+ import { writeFileSync, readFileSync, renameSync, existsSync, mkdirSync, unlinkSync, readdirSync } from 'fs';
5
+ import { join } from 'path';
6
+ import { homedir } from 'os';
7
+ import { logger } from './logger.js';
8
+ import { Connector } from './connector.js';
9
+ import { readLocalSkillVersion, validateSkillContent, compareSemver } from './utils/skill-version.js';
10
+ import { verifySkillSignature, signatureRequired, readLastAppliedSignedAtMs, recordAppliedSignedAt, } from './utils/skill-signing.js';
11
+ import { isTradingMode, validateModeTransition, redactTokens } from '@reefclaw/shared';
12
+ import { OPERATOR_WRITE_METHODS } from './types.js';
13
+ import { EventReplayBuffer } from './event-replay-buffer.js';
14
+ const TAG = 'bridge';
15
+ const AUDIT_TAG = 'bridge-audit';
16
+ /** Write a structured audit log line. Goes through the normal logger today —
17
+ * a future PR may tee this to a dedicated audit sink. Must NEVER include raw
18
+ * secrets; use the `fingerprint` field in payload instead of `secret`. */
19
+ function audit(event, payload) {
20
+ // JSON-encoded payload keeps fields greppable and machine-parseable without
21
+ // a dedicated audit schema. Secrets must be redacted BEFORE calling this.
22
+ logger.info(AUDIT_TAG, `${event} ${JSON.stringify(payload)}`);
23
+ }
24
+ /** Best-effort redaction helper — first 4 + last 2 chars, or `***` for short
25
+ * strings. Mirrors plugin/src/config/plugin-config-io.ts:redactCredential. */
26
+ function fingerprint(value) {
27
+ if (!value)
28
+ return '(empty)';
29
+ if (value.length <= 8)
30
+ return '***';
31
+ return `${value.slice(0, 4)}…${value.slice(-2)}`;
32
+ }
33
+ // ---- Event priority throttler ----
34
+ // Critical events (trades, risk, chat, fill errors) are sent immediately.
35
+ // Low-priority events (ticker, candles, market structure, crypto metrics, volume, regime,
36
+ // signals, missions, analytics, decision traces, shadows) are coalesced: only the latest
37
+ // value per event type is kept, flushed every THROTTLE_INTERVAL_MS.
38
+ const THROTTLE_INTERVAL_MS = 500; // Flush low-priority events every 500ms
39
+ /** Events that must NEVER be dropped or delayed */
40
+ const CRITICAL_EVENTS = new Set([
41
+ 'order_update', // Trade fills, submissions, cancellations
42
+ 'fill_error', // Execution failures
43
+ 'risk_update', // Risk breach alerts
44
+ 'trade_journal', // Trade journal entries
45
+ 'state_update', // Agent mode changes (ACTIVE/PAUSED/STOPPED)
46
+ 'trading_mode', // Paper/live mode changes
47
+ 'skill_update_applied', // OTA SKILL.md confirmation
48
+ // All chat events are on the 'chat' channel and always sent immediately
49
+ ]);
50
+ export class Bridge {
51
+ provider;
52
+ connector;
53
+ seqs = {
54
+ market_data: 0,
55
+ trade_events: 0,
56
+ agent_state: 0,
57
+ chat: 0,
58
+ };
59
+ // Throttle buffer: keyed by "channel:event", holds latest payload
60
+ throttleBuffer = new Map();
61
+ throttleTimer = null;
62
+ /**
63
+ * Bounded ring buffer of every frame this bridge has emitted to the relay.
64
+ * On a `reconcile` request the bridge populates `snapshot.gapFill` with
65
+ * everything the browser hasn't seen yet (per-channel `seq > lastSeq`).
66
+ * Defaults: 500 events × 4 channels × 5 min age cap. See
67
+ * event-replay-buffer.ts for the rationale.
68
+ */
69
+ replayBuffer = new EventReplayBuffer();
70
+ /** Current local SKILL.md version (from frontmatter) */
71
+ currentSkillVersion;
72
+ /** Last trading mode observed from either a tradingMode event or a
73
+ * successful set_trading_mode outcome. Used by the skill-side ladder
74
+ * check so the dashboard can be rejected quickly without a plugin
75
+ * round-trip. `null` means "unknown yet" — the plugin is the authority. */
76
+ lastKnownTradingMode = null;
77
+ // Bound listeners for cleanup
78
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
79
+ listeners = [];
80
+ /** rc_ connection token — also authenticates the webapp skill-content pull */
81
+ connectionToken;
82
+ constructor(provider, connectorConfig) {
83
+ this.provider = provider;
84
+ this.currentSkillVersion = readLocalSkillVersion();
85
+ logger.info(TAG, `Local SKILL.md version: ${this.currentSkillVersion ?? 'unknown'}`);
86
+ this.connectionToken = connectorConfig.token;
87
+ this.connector = new Connector(connectorConfig, {
88
+ onRequest: (frame) => this.handleRequest(frame),
89
+ onStateChange: (state) => this.handleStateChange(state),
90
+ onConnectAck: (payload) => this.handleConnectAck(payload),
91
+ });
92
+ }
93
+ /** Start the bridge: connect to relay + start provider */
94
+ start() {
95
+ logger.info(TAG, 'Starting bridge');
96
+ this.wireProviderEvents();
97
+ this.startThrottleTimer();
98
+ this.provider.start();
99
+ this.connector.connect();
100
+ // Fresh installs ship only the thin BOOTSTRAP SKILL.md (v0.0.x) — the full
101
+ // trading instructions live behind the authenticated webapp endpoint, not
102
+ // in the public npm package. Fire-and-forget with retries; the relay OTA
103
+ // path (handleConnectAck) remains the push channel for updates.
104
+ void this.pullSkillContentFromWebapp();
105
+ }
106
+ /** Stop the bridge: disconnect + stop provider */
107
+ stop() {
108
+ logger.info(TAG, 'Stopping bridge');
109
+ this.stopThrottleTimer();
110
+ this.unwireProviderEvents();
111
+ this.connector.destroy();
112
+ this.provider.stop();
113
+ }
114
+ // ---- Wire provider events to connector ----
115
+ wireProviderEvents() {
116
+ this.addListener('ticker', (data) => {
117
+ // Wrap in MarketDataEvent format expected by webapp
118
+ this.emit('market_data', 'ticker', {
119
+ symbol: data.symbol,
120
+ ticker: data,
121
+ timestamp: data.timestamp,
122
+ });
123
+ });
124
+ this.addListener('candle', (data) => {
125
+ this.emit('market_data', 'candle', data);
126
+ });
127
+ this.addListener('orderUpdate', (data) => {
128
+ this.emit('trade_events', 'order_update', data);
129
+ });
130
+ this.addListener('agentState', (data) => {
131
+ this.emit('agent_state', 'state_update', data);
132
+ });
133
+ this.addListener('riskUpdate', (data) => {
134
+ this.emit('agent_state', 'risk_update', data);
135
+ });
136
+ this.addListener('chatMessage', (data) => {
137
+ this.emit('chat', 'message', data);
138
+ });
139
+ // Streaming chat events
140
+ this.addListener('chatMessageStart', (data) => {
141
+ this.emit('chat', 'message_start', data);
142
+ });
143
+ this.addListener('chatMessageChunk', (data) => {
144
+ this.emit('chat', 'message_chunk', data);
145
+ });
146
+ this.addListener('chatMessageEnd', (data) => {
147
+ this.emit('chat', 'message_end', data);
148
+ });
149
+ this.addListener('chatStreamingKeepAlive', (data) => {
150
+ this.emit('chat', 'streaming_keepalive', data);
151
+ });
152
+ this.addListener('marketStructure', (data) => {
153
+ this.emit('agent_state', 'market_structure', {
154
+ event: 'market_structure',
155
+ ...data,
156
+ });
157
+ });
158
+ this.addListener('cryptoMetrics', (data) => {
159
+ this.emit('agent_state', 'crypto_metrics', {
160
+ event: 'crypto_metrics',
161
+ ...data,
162
+ });
163
+ });
164
+ this.addListener('volumeAnalysis', (data) => {
165
+ this.emit('agent_state', 'volume_analysis', {
166
+ event: 'volume_analysis',
167
+ ...data,
168
+ });
169
+ });
170
+ this.addListener('tradeJournal', (data) => {
171
+ this.emit('trade_events', 'trade_journal', {
172
+ event: 'trade_journal',
173
+ ...data,
174
+ });
175
+ });
176
+ this.addListener('regimeUpdate', (data) => {
177
+ this.emit('agent_state', 'regime_update', data);
178
+ });
179
+ this.addListener('signalUpdate', (data) => {
180
+ this.emit('agent_state', 'signal_update', data);
181
+ });
182
+ this.addListener('missionUpdate', (data) => {
183
+ this.emit('agent_state', 'mission_update', data);
184
+ });
185
+ this.addListener('analyticsUpdate', (data) => {
186
+ this.emit('agent_state', 'analytics_update', data);
187
+ });
188
+ this.addListener('fillError', (data) => {
189
+ this.emit('agent_state', 'fill_error', {
190
+ event: 'fill_error',
191
+ orderId: data.orderId,
192
+ symbol: data.symbol,
193
+ error: data.error,
194
+ timestamp: data.timestamp,
195
+ });
196
+ });
197
+ // Phase 9b: Shadow mode events
198
+ // Normalize side/type from CCXT lowercase to protocol uppercase
199
+ this.addListener('shadowComparison', (data) => {
200
+ this.emit('agent_state', 'shadow_comparison', {
201
+ ...data,
202
+ side: data.side.toUpperCase(),
203
+ type: data.type.toUpperCase(),
204
+ });
205
+ });
206
+ this.addListener('tradingModeUpdate', (data) => {
207
+ // Keep the skill-side ladder cache in sync with the plugin's view.
208
+ this.lastKnownTradingMode = data.mode;
209
+ this.emit('agent_state', 'trading_mode', data);
210
+ });
211
+ // Phase 9c: Decision trace events
212
+ this.addListener('decisionTraceUpdate', (data) => {
213
+ this.emit('agent_state', 'decision_trace', data);
214
+ });
215
+ }
216
+ addListener(event, fn) {
217
+ this.provider.on(event, fn);
218
+ this.listeners.push({ event, fn });
219
+ }
220
+ unwireProviderEvents() {
221
+ for (const { event, fn } of this.listeners) {
222
+ this.provider.off(event, fn);
223
+ }
224
+ this.listeners.length = 0;
225
+ }
226
+ // ---- Emit event frames with seq tracking + priority throttling ----
227
+ emit(channel, event, payload) {
228
+ // Deliberately NO `isConnected()` short-circuit. Every emitted event must
229
+ // reach `sendFrame` so it lands in the replay buffer — that buffer is
230
+ // what we replay via `gapFill` when a browser reconciles after the
231
+ // skill→relay link recovers from a transient drop. The wire-side send
232
+ // inside sendFrame is gated by the connector and no-ops cleanly when the
233
+ // socket is closed, so emitting while disconnected is safe.
234
+ // Critical events and all chat events: send immediately, never throttle
235
+ if (channel === 'chat' || CRITICAL_EVENTS.has(event)) {
236
+ this.sendFrame(channel, event, payload);
237
+ return;
238
+ }
239
+ // Low-priority events: coalesce in buffer, flush periodically.
240
+ // Key by symbol when present so multi-symbol data doesn't overwrite each other.
241
+ let key = `${channel}:${event}`;
242
+ if (payload && typeof payload === 'object' && 'symbol' in payload) {
243
+ key = `${channel}:${event}:${payload.symbol}`;
244
+ }
245
+ this.throttleBuffer.set(key, { channel, event, payload });
246
+ }
247
+ /** Send a frame to the relay AND record it in the replay buffer.
248
+ *
249
+ * Both writes happen unconditionally:
250
+ * - replayBuffer.record() is the canonical wire-history; gapFill draws
251
+ * from it on the next reconcile, even if this frame's wire send was
252
+ * a no-op because the connector was disconnected.
253
+ * - connector.sendEvent() internally checks readyState and silently
254
+ * no-ops when the socket is closed (connector.ts:164), so calling it
255
+ * during a disconnect is safe and we don't have to mirror the gate
256
+ * here. */
257
+ sendFrame(channel, event, payload) {
258
+ const seq = this.seqs[channel]++;
259
+ const frame = {
260
+ type: 'event',
261
+ event,
262
+ channel,
263
+ payload,
264
+ seq,
265
+ };
266
+ this.replayBuffer.record(channel, event, payload, seq);
267
+ this.connector.sendEvent(frame);
268
+ }
269
+ /** Flush all buffered low-priority events (latest value per type).
270
+ *
271
+ * Drains through sendFrame regardless of connector state — the replay
272
+ * buffer is the authoritative record and must capture coalesced events
273
+ * even when the link is down. The wire send inside sendFrame is a no-op
274
+ * until the connector reconnects, at which point new events flow live and
275
+ * the offline coalesced ones are available via gapFill on reconcile. */
276
+ flushThrottleBuffer() {
277
+ if (this.throttleBuffer.size === 0)
278
+ return;
279
+ for (const [, { channel, event, payload }] of this.throttleBuffer) {
280
+ this.sendFrame(channel, event, payload);
281
+ }
282
+ this.throttleBuffer.clear();
283
+ }
284
+ startThrottleTimer() {
285
+ this.stopThrottleTimer();
286
+ this.throttleTimer = setInterval(() => this.flushThrottleBuffer(), THROTTLE_INTERVAL_MS);
287
+ }
288
+ stopThrottleTimer() {
289
+ if (this.throttleTimer) {
290
+ clearInterval(this.throttleTimer);
291
+ this.throttleTimer = null;
292
+ }
293
+ // Flush remaining on stop
294
+ this.flushThrottleBuffer();
295
+ }
296
+ // ---- Handle incoming requests ----
297
+ async handleRequest(frame) {
298
+ const { id, method, params } = frame;
299
+ try {
300
+ // ---- Operator-write scope gate ----
301
+ // Methods in OPERATOR_WRITE_METHODS require an elevated scope. Today,
302
+ // all connected sessions inherit operator.write from the relay-level
303
+ // authentication (Clerk-issued token), so this check is effectively a
304
+ // whitelist — it exists so a future PR can attach per-session roles
305
+ // without restructuring the dispatcher. Emergency methods are on the
306
+ // list for the same reason (they are already covered today, but the
307
+ // list is the forward-compatible gate).
308
+ if (OPERATOR_WRITE_METHODS.has(method) && !this.hasOperatorWriteScope()) {
309
+ audit('operator_write.denied', { method, id });
310
+ this.connector.sendResponse(id, false, undefined, {
311
+ code: 403,
312
+ message: 'operator.write scope required',
313
+ });
314
+ return;
315
+ }
316
+ if (method.startsWith('emergency.')) {
317
+ const action = method.split('.')[1];
318
+ logger.info(TAG, `Emergency command: ${action}`);
319
+ const result = await this.provider.executeEmergency(action);
320
+ this.connector.sendResponse(id, true, result);
321
+ return;
322
+ }
323
+ if (method === 'reconcile') {
324
+ logger.info(TAG, 'Reconciliation requested');
325
+ const snapshot = await this.provider.getSnapshot();
326
+ const lastSequences = parseLastSequences(params);
327
+ const gapFill = this.replayBuffer
328
+ .sinceMany(lastSequences)
329
+ .map((e) => ({
330
+ type: 'event',
331
+ channel: e.channel,
332
+ event: e.event,
333
+ payload: e.payload,
334
+ seq: e.seq,
335
+ }));
336
+ if (gapFill.length > 0) {
337
+ logger.info(TAG, `gapFill: ${gapFill.length} events replayed (lastSeqs=${JSON.stringify(lastSequences)})`);
338
+ }
339
+ // Provider hardcodes gapFill: []; bridge owns the wire-side seq
340
+ // counter so it is the canonical source. Overwrite unconditionally.
341
+ const enriched = { ...snapshot, gapFill };
342
+ this.connector.sendResponse(id, true, enriched);
343
+ return;
344
+ }
345
+ if (method === 'chat.send') {
346
+ const rawContent = params?.content ?? '';
347
+ // Redact rc_* tokens BEFORE we touch the message anywhere downstream:
348
+ // - the log line below
349
+ // - the provider (which may forward to the OpenClaw agent's session
350
+ // JSONL, the mock provider's stdout, etc.)
351
+ // Onboarding directs users to paste their token into the local
352
+ // OpenClaw terminal; a confused operator pasting into the dashboard
353
+ // chat lands here, and the webapp + relay have already auto-revoked
354
+ // the token by now. Forwarding the raw bytes any further has no
355
+ // legitimate purpose — the agent can react to "rc_***" the same way
356
+ // it would react to the original message.
357
+ const safeContent = redactTokens(rawContent);
358
+ if (safeContent !== rawContent) {
359
+ logger.warn(TAG, 'Chat contained a connection token — webapp/relay should have auto-revoked. ' +
360
+ 'If the agent is still connected after this, generate a new token in Settings.');
361
+ }
362
+ // Approval Mode Phase C — when the operator is discussing a pending
363
+ // proposal in the chat panel, the webapp attaches a compact blob so
364
+ // we can prefix a context note. The agent then knows which trade
365
+ // the operator is asking about for THIS turn, and may emit a new
366
+ // create_order with `supersedes_id` to modify the proposal.
367
+ // Fail-open: if the context is malformed, we just pass through the
368
+ // raw chat content without crashing the bridge.
369
+ let augmentedContent = safeContent;
370
+ const proposalContext = params?.proposalContext;
371
+ if (proposalContext && typeof proposalContext === 'object') {
372
+ try {
373
+ const c = proposalContext;
374
+ const header = `[Operator is discussing your pending proposal — ` +
375
+ `id=${c.id} ${c.symbol} ${c.side} size=${c.size} ` +
376
+ `entry=${c.proposedEntry} stop=${c.stopPrice} target=${c.targetPrice} ` +
377
+ `risk=$${c.riskUsd} setup=${c.setupType} regime=${c.regime} ` +
378
+ `conf=${c.confluenceScore}. Thesis: ${String(c.thesis ?? '').slice(0, 200)}. ` +
379
+ `If they want a modification, use create_order with supersedes_id=${c.id}.]\n\n`;
380
+ augmentedContent = header + safeContent;
381
+ }
382
+ catch (err) {
383
+ logger.warn(TAG, `Failed to render proposalContext header: ${String(err)}`);
384
+ }
385
+ }
386
+ // Phase 4 learning curation — operator may be discussing a learning
387
+ // rule (confirmed / hypothesis / retired). The webapp pins the
388
+ // learning via the LearningsTab 💬 button and attaches a compact
389
+ // blob with the learning's identity, directive, trigger, and
390
+ // mined-pattern evidence stats. The agent's job in that turn is to
391
+ // help the operator refine the rule — soften/tighten the trigger,
392
+ // re-word the directive, point out gaps in evidence, or argue for
393
+ // retiring/re-confirming. The agent does NOT mutate the learning
394
+ // directly; concrete edits flow through the operator's Edit dialog.
395
+ // Independent from proposalContext — both may fire on the same turn.
396
+ const learningContext = params?.learningContext;
397
+ if (learningContext && typeof learningContext === 'object') {
398
+ try {
399
+ const l = learningContext;
400
+ const trig = l.triggerCondition && typeof l.triggerCondition === 'object'
401
+ ? Object.entries(l.triggerCondition)
402
+ .map(([k, v]) => `${k}=${Array.isArray(v) ? v.join('|') : String(v)}`)
403
+ .join(' ')
404
+ : '∅';
405
+ const evidence = (typeof l.sampleSize === 'number' ? `n=${l.sampleSize}` : '') +
406
+ (typeof l.effect === 'number' ? ` Δ=${l.effect.toFixed(2)}R` : '') +
407
+ (typeof l.pValue === 'number' ? ` p=${l.pValue.toFixed(3)}` : '');
408
+ const header = `[Operator is discussing learning id=${l.id} (${l.confidence} · ${l.appliesAt}) ` +
409
+ `"${String(l.title ?? '').slice(0, 120)}". ` +
410
+ `Directive: ${String(l.directive ?? '').slice(0, 400)} | ` +
411
+ `Trigger: ${trig} | Evidence: ${evidence || 'none'}. ` +
412
+ `Help them refine: argue for softer/tighter triggers, more specific override ` +
413
+ `conditions, or that the pattern no longer holds. You do NOT mutate the ` +
414
+ `learning directly — concrete edits flow through the operator's UI.]\n\n`;
415
+ augmentedContent = header + augmentedContent;
416
+ }
417
+ catch (err) {
418
+ logger.warn(TAG, `Failed to render learningContext header: ${String(err)}`);
419
+ }
420
+ }
421
+ logger.info(TAG, `Chat received${proposalContext ? ' (with proposal context)' : ''}${learningContext ? ' (with learning context)' : ''}: "${safeContent.slice(0, 50)}${safeContent.length > 50 ? '...' : ''}"`);
422
+ const result = await this.provider.handleChat(augmentedContent);
423
+ this.connector.sendResponse(id, true, result);
424
+ return;
425
+ }
426
+ if (method === 'close_position') {
427
+ const symbol = params?.symbol ?? '';
428
+ if (!symbol) {
429
+ this.connector.sendResponse(id, false, undefined, { code: 400, message: 'Missing symbol' });
430
+ return;
431
+ }
432
+ logger.info(TAG, `Close position requested: ${symbol}`);
433
+ const result = await this.provider.closePosition(symbol);
434
+ this.connector.sendResponse(id, true, result);
435
+ return;
436
+ }
437
+ if (method === 'set_trading_mode') {
438
+ const result = await this.handleSetTradingMode(id, params);
439
+ if (result !== 'responded') {
440
+ // Handler returned a structured outcome — forward as success frame.
441
+ this.connector.sendResponse(id, true, result);
442
+ }
443
+ return;
444
+ }
445
+ if (method === 'set_exchange_credentials') {
446
+ const result = await this.handleSetExchangeCredentials(id, params);
447
+ if (result !== 'responded') {
448
+ this.connector.sendResponse(id, true, result);
449
+ }
450
+ return;
451
+ }
452
+ if (method === 'test_exchange_credentials') {
453
+ const result = await this.handleTestExchangeCredentials(id, params);
454
+ if (result !== 'responded') {
455
+ this.connector.sendResponse(id, true, result);
456
+ }
457
+ return;
458
+ }
459
+ if (method === 'clear_exchange_credentials') {
460
+ const result = await this.handleClearExchangeCredentials(id);
461
+ this.connector.sendResponse(id, true, result);
462
+ return;
463
+ }
464
+ if (method === 'get_bracket_config') {
465
+ if (!this.provider.getBracketConfig) {
466
+ this.connector.sendResponse(id, false, undefined, {
467
+ code: 501,
468
+ message: 'Provider does not support getBracketConfig',
469
+ });
470
+ return;
471
+ }
472
+ const result = await this.provider.getBracketConfig();
473
+ this.connector.sendResponse(id, true, result);
474
+ return;
475
+ }
476
+ if (method === 'set_bracket_requirement') {
477
+ const flag = params?.flag;
478
+ const value = params?.value;
479
+ if ((flag !== 'requireStopLoss' && flag !== 'requireTakeProfit') || typeof value !== 'boolean') {
480
+ this.connector.sendResponse(id, false, undefined, {
481
+ code: 400,
482
+ message: 'set_bracket_requirement requires { flag: "requireStopLoss"|"requireTakeProfit", value: boolean }',
483
+ });
484
+ return;
485
+ }
486
+ if (!this.provider.setBracketRequirement) {
487
+ this.connector.sendResponse(id, false, undefined, {
488
+ code: 501,
489
+ message: 'Provider does not support setBracketRequirement',
490
+ });
491
+ return;
492
+ }
493
+ logger.info(TAG, `set_bracket_requirement ${flag}=${value}`);
494
+ const result = await this.provider.setBracketRequirement(flag, value);
495
+ this.connector.sendResponse(id, true, result);
496
+ return;
497
+ }
498
+ if (method === 'skill.update') {
499
+ // SECURITY: the frame's content is UNTRUSTED. The relay forwards
500
+ // browser request frames to the skill, so (on a relay without the
501
+ // matching browser-side block) any authenticated dashboard session
502
+ // could forge a `skill.update` req carrying attacker instructions.
503
+ // Treat the frame as a DOORBELL only: ignore its payload and pull the
504
+ // authoritative envelope from the relay's Durable-Object store, which
505
+ // is writable exclusively through the ADMIN_SECRET-gated HTTP push
506
+ // (party.ts handleSkillPush). A forged frame can therefore at most
507
+ // trigger (re)application of the legitimately-stored SKILL.md — and
508
+ // the C1 signature gate inside applySkillUpdate still applies when
509
+ // enforcement is on.
510
+ const pushedVersion = typeof params?.version === 'string' ? params.version : 'unknown';
511
+ logger.info(TAG, `Received SKILL.md OTA update notification (v${pushedVersion}) — pulling authoritative copy from relay store`);
512
+ let result;
513
+ try {
514
+ result = await this.pullAndApplySkillUpdate();
515
+ }
516
+ catch (pullErr) {
517
+ this.connector.sendResponse(id, false, undefined, {
518
+ code: 502,
519
+ message: `Failed to fetch SKILL.md from relay store: ${pullErr instanceof Error ? pullErr.message : String(pullErr)}`,
520
+ });
521
+ return;
522
+ }
523
+ if (!result) {
524
+ this.connector.sendResponse(id, false, undefined, {
525
+ code: 404,
526
+ message: 'No stored SKILL.md on relay — push via the admin HTTP path first',
527
+ });
528
+ return;
529
+ }
530
+ this.connector.sendResponse(id, true, result);
531
+ return;
532
+ }
533
+ // Should not reach here — connector whitelist blocks unknown methods
534
+ this.connector.sendResponse(id, false, undefined, {
535
+ code: 400,
536
+ message: `Unhandled method: ${method}`,
537
+ });
538
+ }
539
+ catch (err) {
540
+ const message = err instanceof Error ? err.message : String(err);
541
+ logger.error(TAG, `Error handling ${method}: ${message}`);
542
+ this.connector.sendResponse(id, false, undefined, {
543
+ code: 500,
544
+ message,
545
+ });
546
+ }
547
+ }
548
+ handleStateChange(state) {
549
+ logger.info(TAG, `Connector state: ${state}`);
550
+ }
551
+ // ---- Operator onboarding (PR2) ----
552
+ /** Today every authenticated session carries operator.write via the
553
+ * relay-level Clerk token. This method exists as the enforcement seam —
554
+ * a future PR can drop per-session roles in here without restructuring
555
+ * the dispatcher. Returning true keeps current behavior identical. */
556
+ hasOperatorWriteScope() {
557
+ return true;
558
+ }
559
+ /** Handle a set_trading_mode request. Returns 'responded' if the handler
560
+ * already wrote the response frame (e.g. on validation failure), otherwise
561
+ * returns the structured outcome for the caller to wrap in a success
562
+ * frame. Ladder validation runs here for fast dashboard feedback and
563
+ * again in the plugin (defense in depth). */
564
+ async handleSetTradingMode(id, params) {
565
+ const rawMode = params?.mode;
566
+ const acknowledged = params?.acknowledged === true;
567
+ if (!isTradingMode(rawMode)) {
568
+ this.connector.sendResponse(id, false, undefined, {
569
+ code: 400,
570
+ message: 'mode must be one of PAPER, MICRO_LIVE, LIVE',
571
+ });
572
+ return 'responded';
573
+ }
574
+ // Fast skill-side ladder check so the dashboard gets an error without
575
+ // round-tripping to the plugin. We use the current tradingMode from the
576
+ // latest snapshot — if we don't have one yet, skip the check and let the
577
+ // plugin enforce.
578
+ const currentMode = this.lastKnownTradingMode;
579
+ if (currentMode) {
580
+ const ladder = validateModeTransition(currentMode, rawMode);
581
+ if (!ladder.allowed) {
582
+ audit('set_trading_mode.rejected', {
583
+ id,
584
+ from: currentMode,
585
+ to: rawMode,
586
+ reason: 'ladder',
587
+ });
588
+ return {
589
+ ok: false,
590
+ message: ladder.reason ?? 'Mode transition not allowed',
591
+ previousMode: currentMode,
592
+ mode: currentMode,
593
+ readiness: 'UNKNOWN',
594
+ reason: 'ladder',
595
+ };
596
+ }
597
+ }
598
+ const provider = this.provider;
599
+ if (!provider.setTradingMode) {
600
+ return {
601
+ ok: false,
602
+ message: 'Provider does not support set_trading_mode (likely mock provider)',
603
+ previousMode: currentMode ?? 'PAPER',
604
+ mode: currentMode ?? 'PAPER',
605
+ readiness: 'UNKNOWN',
606
+ };
607
+ }
608
+ audit('set_trading_mode.start', { id, from: currentMode ?? 'unknown', to: rawMode, acknowledged });
609
+ const outcome = await provider.setTradingMode(rawMode, acknowledged);
610
+ audit('set_trading_mode.complete', {
611
+ id,
612
+ ok: outcome.ok,
613
+ previousMode: outcome.previousMode,
614
+ mode: outcome.mode,
615
+ reason: outcome.reason,
616
+ });
617
+ if (outcome.ok) {
618
+ this.lastKnownTradingMode = outcome.mode;
619
+ }
620
+ return outcome;
621
+ }
622
+ /** Handle a set_exchange_credentials request. The secret value passes
623
+ * through once and is never cached or logged; only a redacted fingerprint
624
+ * appears in the audit trail. */
625
+ async handleSetExchangeCredentials(id, params) {
626
+ const apiKey = typeof params?.apiKey === 'string' ? params.apiKey : '';
627
+ const secret = typeof params?.secret === 'string' ? params.secret : '';
628
+ const testnet = params?.testnet === true;
629
+ if (apiKey.length < 8 || secret.length < 8) {
630
+ this.connector.sendResponse(id, false, undefined, {
631
+ code: 400,
632
+ message: 'apiKey and secret must be at least 8 characters each',
633
+ });
634
+ return 'responded';
635
+ }
636
+ const fp = fingerprint(apiKey);
637
+ const provider = this.provider;
638
+ if (!provider.setExchangeCredentials) {
639
+ return {
640
+ ok: false,
641
+ message: 'Provider does not support set_exchange_credentials (likely mock provider)',
642
+ fingerprint: fp,
643
+ reconnected: false,
644
+ mode: this.lastKnownTradingMode ?? 'PAPER',
645
+ readiness: 'UNKNOWN',
646
+ };
647
+ }
648
+ audit('set_exchange_credentials.start', { id, fingerprint: fp, testnet });
649
+ const outcome = await provider.setExchangeCredentials(apiKey, secret, testnet);
650
+ audit('set_exchange_credentials.complete', {
651
+ id,
652
+ ok: outcome.ok,
653
+ fingerprint: outcome.fingerprint,
654
+ reconnected: outcome.reconnected,
655
+ mode: outcome.mode,
656
+ });
657
+ return outcome;
658
+ }
659
+ /** Handle a test_exchange_credentials request — transient read-only
660
+ * verification, nothing persisted. Same input validation + secret
661
+ * redaction rules as set_exchange_credentials. */
662
+ async handleTestExchangeCredentials(id, params) {
663
+ const apiKey = typeof params?.apiKey === 'string' ? params.apiKey : '';
664
+ const secret = typeof params?.secret === 'string' ? params.secret : '';
665
+ const testnet = params?.testnet === true;
666
+ if (apiKey.length < 8 || secret.length < 8) {
667
+ this.connector.sendResponse(id, false, undefined, {
668
+ code: 400,
669
+ message: 'apiKey and secret must be at least 8 characters each',
670
+ });
671
+ return 'responded';
672
+ }
673
+ const fp = fingerprint(apiKey);
674
+ const provider = this.provider;
675
+ if (!provider.testExchangeCredentials) {
676
+ return {
677
+ ok: false,
678
+ message: 'Provider does not support test_exchange_credentials (likely mock provider)',
679
+ fingerprint: fp,
680
+ canReadBalance: false,
681
+ canReadPositions: false,
682
+ balanceUSDT: null,
683
+ testnet,
684
+ errors: ['provider-unsupported'],
685
+ };
686
+ }
687
+ audit('test_exchange_credentials.start', { id, fingerprint: fp, testnet });
688
+ const outcome = await provider.testExchangeCredentials(apiKey, secret, testnet);
689
+ audit('test_exchange_credentials.complete', {
690
+ id,
691
+ ok: outcome.ok,
692
+ fingerprint: outcome.fingerprint,
693
+ canReadBalance: outcome.canReadBalance,
694
+ canReadPositions: outcome.canReadPositions,
695
+ balanceUSDT: outcome.balanceUSDT,
696
+ });
697
+ return outcome;
698
+ }
699
+ /** Handle a clear_exchange_credentials request. No input — the method
700
+ * itself is the "yes I want this" signal; the webapp is expected to
701
+ * gate this behind a confirmation dialog before calling. */
702
+ async handleClearExchangeCredentials(id) {
703
+ const provider = this.provider;
704
+ if (!provider.clearExchangeCredentials) {
705
+ return {
706
+ ok: false,
707
+ message: 'Provider does not support clear_exchange_credentials (likely mock provider)',
708
+ modeBefore: this.lastKnownTradingMode ?? 'PAPER',
709
+ mode: this.lastKnownTradingMode ?? 'PAPER',
710
+ rolledBackToPaper: false,
711
+ readiness: 'UNKNOWN',
712
+ };
713
+ }
714
+ audit('clear_exchange_credentials.start', { id });
715
+ const outcome = await provider.clearExchangeCredentials();
716
+ audit('clear_exchange_credentials.complete', {
717
+ id,
718
+ ok: outcome.ok,
719
+ modeBefore: outcome.modeBefore,
720
+ mode: outcome.mode,
721
+ rolledBackToPaper: outcome.rolledBackToPaper,
722
+ });
723
+ if (outcome.ok && outcome.rolledBackToPaper) {
724
+ this.lastKnownTradingMode = 'PAPER';
725
+ }
726
+ return outcome;
727
+ }
728
+ // ---- OTA SKILL.md update ----
729
+ /** Apply a SKILL.md update: verify signature (C1), write to workspace +
730
+ * installed skill dir, clear session cache, notify agent. */
731
+ async applySkillUpdate(env) {
732
+ const { version, content } = env;
733
+ const workspacePath = join(homedir(), '.openclaw', 'workspace');
734
+ const skillMdPath = join(workspacePath, 'SKILL.md');
735
+ const tempPath = join(workspacePath, 'SKILL.md.tmp');
736
+ const sessionsJsonPath = join(homedir(), '.openclaw', 'agents', 'main', 'sessions', 'sessions.json');
737
+ // True only when the Ed25519 signature was actually checked AND passed —
738
+ // gates the replay-clock write below. An update applied UNVERIFIED
739
+ // (enforcement off) must never advance the clock: an unsigned push with a
740
+ // far-future signedAt would otherwise brick every future legitimately-
741
+ // signed update as "replay".
742
+ let signatureVerified = false;
743
+ try {
744
+ // 0. SIGNATURE GATE (C1) — verify BEFORE touching disk. The relay is a
745
+ // dumb transport: only a payload signed by the operator's offline key
746
+ // is applied. Fail closed when enforced.
747
+ if (signatureRequired()) {
748
+ const verdict = verifySkillSignature(env);
749
+ if (!verdict.ok) {
750
+ logger.error(TAG, `OTA SKILL.md REJECTED (signature): ${verdict.reason}`);
751
+ return { success: false, version, message: `signature rejected: ${verdict.reason}` };
752
+ }
753
+ // Replay/rollback guard: only accept a strictly newer signed artifact.
754
+ const sigMs = new Date(env.signedAt).getTime();
755
+ if (!(sigMs > readLastAppliedSignedAtMs())) {
756
+ logger.error(TAG, `OTA SKILL.md REJECTED (replay): signedAt ${env.signedAt} not newer than last applied`);
757
+ return { success: false, version, message: 'signature rejected: replay (stale signedAt)' };
758
+ }
759
+ signatureVerified = true;
760
+ }
761
+ else if (!env.signature) {
762
+ // C1 (signed OTA) is ON HOLD → verification off by default, so OTA works
763
+ // unsigned as before. To enforce: pin a key + SKILL_OTA_REQUIRE_SIGNATURE=on.
764
+ logger.warn(TAG, 'OTA SKILL.md applied WITHOUT signature verification — signed-OTA (C1) is disabled');
765
+ }
766
+ // 1. Validate content
767
+ const validation = validateSkillContent(content);
768
+ if (!validation.valid) {
769
+ return { success: false, version, message: `Invalid SKILL.md: ${validation.error}` };
770
+ }
771
+ // 2. Atomic write: temp file → rename (workspace copy)
772
+ mkdirSync(workspacePath, { recursive: true });
773
+ writeFileSync(tempPath, content, 'utf-8');
774
+ renameSync(tempPath, skillMdPath);
775
+ // 2b. Also write to the installed skill directory — this is what OpenClaw's
776
+ // skill system actually reads for agent instructions (not the workspace copy).
777
+ // Scan ~/.openclaw/skills/ for any reefclaw-related skill directories.
778
+ const skillsDir = join(homedir(), '.openclaw', 'skills');
779
+ if (existsSync(skillsDir)) {
780
+ try {
781
+ const entries = readdirSync(skillsDir, { withFileTypes: true });
782
+ for (const entry of entries) {
783
+ if (entry.isDirectory() && entry.name.includes('reefclaw')) {
784
+ const installedSkillMd = join(skillsDir, entry.name, 'SKILL.md');
785
+ writeFileSync(installedSkillMd, content, 'utf-8');
786
+ logger.info(TAG, `Updated installed skill SKILL.md: ${installedSkillMd}`);
787
+ }
788
+ }
789
+ }
790
+ catch (scanErr) {
791
+ logger.warn(TAG, `Failed to update installed skill SKILL.md: ${scanErr instanceof Error ? scanErr.message : String(scanErr)}`);
792
+ }
793
+ }
794
+ // 2c. Also write to workspace/skills/ subdirectory if it exists
795
+ const wsSkillsDir = join(workspacePath, 'skills');
796
+ if (existsSync(wsSkillsDir)) {
797
+ try {
798
+ const entries = readdirSync(wsSkillsDir, { withFileTypes: true });
799
+ for (const entry of entries) {
800
+ if (entry.isDirectory() && entry.name.includes('reefclaw')) {
801
+ const wsSkillMd = join(wsSkillsDir, entry.name, 'SKILL.md');
802
+ writeFileSync(wsSkillMd, content, 'utf-8');
803
+ logger.info(TAG, `Updated workspace skill SKILL.md: ${wsSkillMd}`);
804
+ }
805
+ }
806
+ }
807
+ catch (scanErr) {
808
+ logger.warn(TAG, `Failed to update workspace skill SKILL.md: ${scanErr instanceof Error ? scanErr.message : String(scanErr)}`);
809
+ }
810
+ }
811
+ // 3. Invalidate skillsSnapshot cache WITHOUT wiping sessions.json
812
+ // Previously we wrote '{}' to sessions.json which destroyed OpenClaw chat history.
813
+ // Now we surgically remove only the skillsSnapshot key from each session entry,
814
+ // preserving the session pointer and chat history.
815
+ if (existsSync(sessionsJsonPath)) {
816
+ try {
817
+ const raw = readFileSync(sessionsJsonPath, 'utf-8');
818
+ const sessions = JSON.parse(raw);
819
+ let patched = false;
820
+ for (const key of Object.keys(sessions)) {
821
+ const entry = sessions[key];
822
+ if (entry && typeof entry === 'object' && 'skillsSnapshot' in entry) {
823
+ delete entry.skillsSnapshot;
824
+ patched = true;
825
+ }
826
+ }
827
+ if (patched) {
828
+ writeFileSync(sessionsJsonPath, JSON.stringify(sessions, null, 2), 'utf-8');
829
+ logger.info(TAG, 'Removed skillsSnapshot from sessions.json — agent will re-read SKILL.md (chat history preserved)');
830
+ }
831
+ else {
832
+ logger.info(TAG, 'No skillsSnapshot found in sessions.json — nothing to invalidate');
833
+ }
834
+ }
835
+ catch (parseErr) {
836
+ // If sessions.json is corrupted or unparseable, fall back to resetting it
837
+ logger.warn(TAG, `Failed to patch sessions.json, resetting: ${parseErr instanceof Error ? parseErr.message : String(parseErr)}`);
838
+ writeFileSync(sessionsJsonPath, '{}', 'utf-8');
839
+ }
840
+ }
841
+ // 4. Send chat message to agent to trigger immediate re-read
842
+ try {
843
+ await this.provider.handleChat(`Your trading instructions (SKILL.md) have been updated to v${version}. ` +
844
+ `Check for any open positions with fetch_positions() and fetch_open_orders(), then re-read SKILL.md to confirm you've processed the changes.`);
845
+ }
846
+ catch (chatErr) {
847
+ logger.warn(TAG, `Chat notification failed (non-fatal): ${chatErr instanceof Error ? chatErr.message : String(chatErr)}`);
848
+ }
849
+ // 5. Update local version tracker + advance the replay clock. The clock
850
+ // advances ONLY on a VERIFIED signature: recording an unverified
851
+ // signedAt (enforcement off) would let an unsigned push with a
852
+ // far-future timestamp poison the clock and reject all future
853
+ // legitimately-signed updates as replays.
854
+ this.currentSkillVersion = version;
855
+ if (signatureVerified && env.signedAt)
856
+ recordAppliedSignedAt(env.signedAt);
857
+ logger.info(TAG, `SKILL.md updated to v${version} successfully`);
858
+ return { success: true, version, message: `Updated to v${version}` };
859
+ }
860
+ catch (err) {
861
+ const message = err instanceof Error ? err.message : String(err);
862
+ logger.error(TAG, `Failed to apply SKILL.md update: ${message}`);
863
+ try {
864
+ unlinkSync(tempPath);
865
+ }
866
+ catch { /* ignore cleanup failure */ }
867
+ return { success: false, version, message };
868
+ }
869
+ }
870
+ /**
871
+ * Fetch the stored SKILL.md envelope from the relay's Durable-Object store
872
+ * and apply it. The DO store is writable exclusively through the
873
+ * ADMIN_SECRET-gated HTTP push (party.ts handleSkillPush) — pulling makes
874
+ * the STORE, not any WS frame, the trust anchor for OTA content. Refuses to
875
+ * downgrade below the local version (a stale relay copy must never clobber
876
+ * a newer local SKILL.md — prod incident 2026-05-13); an equal version
877
+ * re-applies (idempotent repair for a re-push). Returns null when the relay
878
+ * has nothing stored; throws on transport error.
879
+ */
880
+ async pullAndApplySkillUpdate() {
881
+ const result = await this.connector.sendRequest('skill.request_update', {});
882
+ if (!result?.content)
883
+ return null;
884
+ // Re-read local version (may have changed since startup) + downgrade guard.
885
+ this.currentSkillVersion = readLocalSkillVersion();
886
+ if (compareSemver(result.version, this.currentSkillVersion) < 0) {
887
+ const message = `refusing downgrade: relay v${result.version} is older than local v${this.currentSkillVersion ?? 'unknown'}`;
888
+ logger.warn(TAG, `SKILL.md pull: ${message}`);
889
+ return { success: false, version: result.version, message };
890
+ }
891
+ const updateResult = await this.applySkillUpdate({
892
+ version: result.version,
893
+ content: result.content,
894
+ contentSha256: result.contentSha256,
895
+ signedAt: result.signedAt,
896
+ signature: result.signature,
897
+ });
898
+ // Confirmation event to the dashboard — the operator pushed something and
899
+ // should see the outcome either way (relay audits skill_update_applied).
900
+ this.emit('agent_state', 'skill_update_applied', {
901
+ event: 'skill_update_applied',
902
+ version: result.version,
903
+ success: updateResult.success,
904
+ timestamp: new Date().toISOString(),
905
+ });
906
+ return updateResult;
907
+ }
908
+ /**
909
+ * Pull the full SKILL.md from the webapp's authenticated endpoint and apply
910
+ * it when newer than local. This is how a FRESH install upgrades from the
911
+ * bundled bootstrap (v0.0.x) to the real trading instructions — the relay's
912
+ * per-room Durable-Object store is empty for a new user, so the relay push
913
+ * channel alone can never deliver the first full copy. Token-gated: no valid
914
+ * rc_ token → 401 → no content (the full SKILL.md never ships unauthenticated).
915
+ * Same downgrade guard + applySkillUpdate pipeline (incl. the C1 signature
916
+ * gate when enforced) as the relay pull path.
917
+ */
918
+ async pullSkillContentFromWebapp(attempt = 1) {
919
+ const MAX_ATTEMPTS = 5;
920
+ // www is load-bearing: reefclaw.com 307-redirects and Node fetch strips the
921
+ // Authorization header on cross-origin redirect (verified 2026-03-18).
922
+ const base = (process.env.REEFCLAW_API_URL || 'https://www.reefclaw.com').replace(/\/$/, '');
923
+ try {
924
+ const res = await fetch(`${base}/api/internal/skill-content`, {
925
+ headers: { Authorization: `Bearer ${this.connectionToken}` },
926
+ signal: AbortSignal.timeout(15_000),
927
+ });
928
+ if (res.status === 401 || res.status === 403) {
929
+ logger.warn(TAG, `SKILL.md webapp pull: not authorized (${res.status}) — connect the account first`);
930
+ return; // A bad token won't get better by retrying.
931
+ }
932
+ if (!res.ok)
933
+ throw new Error(`HTTP ${res.status}`);
934
+ const body = (await res.json());
935
+ if (!body?.version || !body?.content) {
936
+ logger.warn(TAG, 'SKILL.md webapp pull: response missing version/content');
937
+ return;
938
+ }
939
+ this.currentSkillVersion = readLocalSkillVersion();
940
+ if (compareSemver(body.version, this.currentSkillVersion) <= 0) {
941
+ logger.info(TAG, `SKILL.md webapp pull: local v${this.currentSkillVersion ?? 'unknown'} already >= webapp v${body.version} — nothing to do`);
942
+ return;
943
+ }
944
+ logger.info(TAG, `SKILL.md webapp pull: local v${this.currentSkillVersion ?? 'unknown'} behind webapp v${body.version} — applying`);
945
+ const result = await this.applySkillUpdate({ version: body.version, content: body.content });
946
+ this.emit('agent_state', 'skill_update_applied', {
947
+ event: 'skill_update_applied',
948
+ version: body.version,
949
+ success: result.success,
950
+ source: 'webapp_pull',
951
+ timestamp: new Date().toISOString(),
952
+ });
953
+ if (!result.success)
954
+ logger.error(TAG, `SKILL.md webapp pull: apply failed: ${result.message}`);
955
+ }
956
+ catch (err) {
957
+ const msg = err instanceof Error ? err.message : String(err);
958
+ if (attempt >= MAX_ATTEMPTS) {
959
+ logger.error(TAG, `SKILL.md webapp pull failed after ${MAX_ATTEMPTS} attempts: ${msg}`);
960
+ return;
961
+ }
962
+ const delayMs = attempt * 30_000;
963
+ logger.warn(TAG, `SKILL.md webapp pull failed (attempt ${attempt}/${MAX_ATTEMPTS}): ${msg} — retrying in ${delayMs / 1000}s`);
964
+ setTimeout(() => void this.pullSkillContentFromWebapp(attempt + 1), delayMs).unref?.();
965
+ }
966
+ }
967
+ /** Check relay's latest SKILL.md version against local and auto-update if stale */
968
+ async handleConnectAck(payload) {
969
+ const latestVersion = payload.latestSkillVersion;
970
+ if (!latestVersion)
971
+ return;
972
+ // Re-read local version (may have changed since startup)
973
+ this.currentSkillVersion = readLocalSkillVersion();
974
+ const cmp = compareSemver(latestVersion, this.currentSkillVersion);
975
+ if (cmp === 0) {
976
+ logger.debug(TAG, `SKILL.md up to date (v${latestVersion})`);
977
+ return;
978
+ }
979
+ if (cmp < 0) {
980
+ // Local is AHEAD of relay. Never downgrade: a stale relay copy must not
981
+ // clobber a freshly SFTP'd or hand-edited SKILL.md. (Prod incident
982
+ // 2026-05-13: relay was at v2.14 but local was v2.15; the old `===`
983
+ // check treated "not equal" as "behind" and silently downgraded to
984
+ // whatever the relay delivered — which was v2.11.) Log loudly so it's
985
+ // obvious the relay needs a push.
986
+ logger.warn(TAG, `Relay SKILL.md v${latestVersion} is OLDER than local v${this.currentSkillVersion ?? 'unknown'} — refusing to downgrade. Push current SKILL.md to relay to clear this warning.`);
987
+ return;
988
+ }
989
+ logger.info(TAG, `Local SKILL.md v${this.currentSkillVersion ?? 'unknown'} behind relay v${latestVersion} — requesting update`);
990
+ try {
991
+ await this.pullAndApplySkillUpdate();
992
+ }
993
+ catch (err) {
994
+ logger.warn(TAG, `Failed to fetch SKILL.md update from relay: ${err instanceof Error ? err.message : String(err)}`);
995
+ }
996
+ }
997
+ }
998
+ // ---------------------------------------------------------------------------
999
+ // Helpers
1000
+ // ---------------------------------------------------------------------------
1001
+ const VALID_GAPFILL_CHANNELS = [
1002
+ 'market_data',
1003
+ 'trade_events',
1004
+ 'agent_state',
1005
+ 'chat',
1006
+ ];
1007
+ /**
1008
+ * Parse the `lastSequences` array from a reconcile request into a per-channel
1009
+ * map. Defaults to `-1` (i.e. "send everything you have") for any channel the
1010
+ * client didn't include or that has a malformed entry. Hostile / malformed
1011
+ * input is silently ignored — the gapFill replay is read-only and bounded by
1012
+ * the buffer's age cap, so worst case is a slightly larger response.
1013
+ */
1014
+ function parseLastSequences(params) {
1015
+ const empty = {};
1016
+ for (const ch of VALID_GAPFILL_CHANNELS)
1017
+ empty[ch] = -1;
1018
+ if (!params || typeof params !== 'object')
1019
+ return empty;
1020
+ const ls = params.lastSequences;
1021
+ if (!Array.isArray(ls))
1022
+ return empty;
1023
+ const out = { ...empty };
1024
+ for (const item of ls) {
1025
+ if (!item || typeof item !== 'object')
1026
+ continue;
1027
+ const obj = item;
1028
+ if (typeof obj.channel === 'string' &&
1029
+ VALID_GAPFILL_CHANNELS.includes(obj.channel) &&
1030
+ typeof obj.lastSeq === 'number' &&
1031
+ Number.isFinite(obj.lastSeq)) {
1032
+ out[obj.channel] = obj.lastSeq;
1033
+ }
1034
+ }
1035
+ return out;
1036
+ }