@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.
- package/.gitignore +8 -0
- package/audit/mode-transition-audit.d.ts +11 -0
- package/audit/mode-transition-audit.js +29 -0
- package/balance-utils.d.ts +36 -0
- package/balance-utils.js +98 -0
- package/bridge/bridge.d.ts +109 -0
- package/bridge/bridge.js +1036 -0
- package/bridge/config.d.ts +43 -0
- package/bridge/config.js +176 -0
- package/bridge/connector.d.ts +52 -0
- package/bridge/connector.js +293 -0
- package/bridge/event-replay-buffer.d.ts +43 -0
- package/bridge/event-replay-buffer.js +109 -0
- package/bridge/gateway/event-parser.d.ts +209 -0
- package/bridge/gateway/event-parser.js +793 -0
- package/bridge/gateway/gateway-config.d.ts +39 -0
- package/bridge/gateway/gateway-config.js +86 -0
- package/bridge/gateway/gateway-http-client.d.ts +50 -0
- package/bridge/gateway/gateway-http-client.js +165 -0
- package/bridge/gateway/gateway-ws-client.d.ts +116 -0
- package/bridge/gateway/gateway-ws-client.js +417 -0
- package/bridge/gateway/poller.d.ts +146 -0
- package/bridge/gateway/poller.js +505 -0
- package/bridge/gateway/tool-discovery.d.ts +25 -0
- package/bridge/gateway/tool-discovery.js +198 -0
- package/bridge/index.d.ts +2 -0
- package/bridge/index.js +253 -0
- package/bridge/logger.d.ts +2 -0
- package/bridge/logger.js +2 -0
- package/bridge/provider.d.ts +156 -0
- package/bridge/provider.js +2 -0
- package/bridge/providers/emergency-commands.d.ts +54 -0
- package/bridge/providers/emergency-commands.js +235 -0
- package/bridge/providers/gateway.d.ts +322 -0
- package/bridge/providers/gateway.js +2302 -0
- package/bridge/providers/mock.d.ts +37 -0
- package/bridge/providers/mock.js +385 -0
- package/bridge/providers/onboarding-commands.d.ts +83 -0
- package/bridge/providers/onboarding-commands.js +213 -0
- package/bridge/providers/risk-calculator.d.ts +96 -0
- package/bridge/providers/risk-calculator.js +369 -0
- package/bridge/setup.d.ts +32 -0
- package/bridge/setup.js +226 -0
- package/bridge/types.d.ts +584 -0
- package/bridge/types.js +50 -0
- package/bridge/utils/reconnect.d.ts +6 -0
- package/bridge/utils/reconnect.js +6 -0
- package/bridge/utils/skill-signing.d.ts +51 -0
- package/bridge/utils/skill-signing.js +138 -0
- package/bridge/utils/skill-version.d.ts +17 -0
- package/bridge/utils/skill-version.js +74 -0
- package/ccxt/binance-ban-gate.d.ts +47 -0
- package/ccxt/binance-ban-gate.js +409 -0
- package/ccxt/binance-private.d.ts +325 -0
- package/ccxt/binance-private.js +1415 -0
- package/ccxt/binance-public.d.ts +18 -0
- package/ccxt/binance-public.js +147 -0
- package/config/agent-config-client.d.ts +55 -0
- package/config/agent-config-client.js +145 -0
- package/config/agent-config-poller.d.ts +25 -0
- package/config/agent-config-poller.js +100 -0
- package/config/brackets-config.d.ts +22 -0
- package/config/brackets-config.js +58 -0
- package/config/gate-store.d.ts +18 -0
- package/config/gate-store.js +61 -0
- package/config/plugin-config-io.d.ts +181 -0
- package/config/plugin-config-io.js +84 -0
- package/config/position-review-config.d.ts +35 -0
- package/config/position-review-config.js +105 -0
- package/config/tool-gate.d.ts +53 -0
- package/config/tool-gate.js +125 -0
- package/config/user-data-stream-config.d.ts +85 -0
- package/config/user-data-stream-config.js +224 -0
- package/connector-supervisor.d.ts +36 -0
- package/connector-supervisor.js +149 -0
- package/exchange-adapter.d.ts +49 -0
- package/exchange-adapter.js +4 -0
- package/index.d.ts +30 -0
- package/index.js +2030 -0
- package/ingest/pending-entry-metadata.d.ts +52 -0
- package/ingest/pending-entry-metadata.js +182 -0
- package/ingest/position-auto-capture.d.ts +98 -0
- package/ingest/position-auto-capture.js +394 -0
- package/ingest/position-decisions-client.d.ts +318 -0
- package/ingest/position-decisions-client.js +296 -0
- package/ingest/reconcile-db-vs-exchange.d.ts +13 -0
- package/ingest/reconcile-db-vs-exchange.js +114 -0
- package/ingest/reconciler-cleanup.d.ts +37 -0
- package/ingest/reconciler-cleanup.js +147 -0
- package/ingest/rest-gap-filler.d.ts +191 -0
- package/ingest/rest-gap-filler.js +565 -0
- package/ingest/touched-symbols-store.d.ts +25 -0
- package/ingest/touched-symbols-store.js +96 -0
- package/ingest/trade-store-client.d.ts +40 -0
- package/ingest/trade-store-client.js +125 -0
- package/ingest/ws-ingest.d.ts +43 -0
- package/ingest/ws-ingest.js +126 -0
- package/learning/setup-family.d.ts +21 -0
- package/learning/setup-family.js +103 -0
- package/lifecycle/install-signal-handlers.d.ts +33 -0
- package/lifecycle/install-signal-handlers.js +112 -0
- package/lifecycle/shutdown-coordinator.d.ts +43 -0
- package/lifecycle/shutdown-coordinator.js +131 -0
- package/live/bracket-id.d.ts +18 -0
- package/live/bracket-id.js +81 -0
- package/live/bracket-ledger.d.ts +54 -0
- package/live/bracket-ledger.js +267 -0
- package/live/bracket-manager.d.ts +82 -0
- package/live/bracket-manager.js +478 -0
- package/live/bracket-params.d.ts +22 -0
- package/live/bracket-params.js +124 -0
- package/live/bracket-reconciler.d.ts +95 -0
- package/live/bracket-reconciler.js +573 -0
- package/live/bracket-types.d.ts +102 -0
- package/live/bracket-types.js +8 -0
- package/live/deposit-tracker.d.ts +62 -0
- package/live/deposit-tracker.js +97 -0
- package/live/emergency-controls.d.ts +32 -0
- package/live/emergency-controls.js +226 -0
- package/live/exchange-errors.d.ts +12 -0
- package/live/exchange-errors.js +130 -0
- package/live/exchange-info-cache.d.ts +35 -0
- package/live/exchange-info-cache.js +119 -0
- package/live/fact-subscriber.d.ts +78 -0
- package/live/fact-subscriber.js +182 -0
- package/live/intent-journal.d.ts +42 -0
- package/live/intent-journal.js +122 -0
- package/live/listen-key-manager.d.ts +70 -0
- package/live/listen-key-manager.js +169 -0
- package/live/live-adapter.d.ts +264 -0
- package/live/live-adapter.js +1665 -0
- package/live/live-balance-enricher.d.ts +32 -0
- package/live/live-balance-enricher.js +104 -0
- package/live/live-bracket-api.d.ts +13 -0
- package/live/live-bracket-api.js +20 -0
- package/live/live-state-store.d.ts +194 -0
- package/live/live-state-store.js +450 -0
- package/live/local-signal-service.d.ts +57 -0
- package/live/local-signal-service.js +146 -0
- package/live/local-strategy-evaluator.d.ts +62 -0
- package/live/local-strategy-evaluator.js +127 -0
- package/live/microstructure-assembler.d.ts +54 -0
- package/live/microstructure-assembler.js +148 -0
- package/live/order-poller.d.ts +29 -0
- package/live/order-poller.js +125 -0
- package/live/position-state-store.d.ts +83 -0
- package/live/position-state-store.js +237 -0
- package/live/proposal-decision-listener.d.ts +64 -0
- package/live/proposal-decision-listener.js +288 -0
- package/live/proposal-manager.d.ts +76 -0
- package/live/proposal-manager.js +140 -0
- package/live/rate-limiter.d.ts +47 -0
- package/live/rate-limiter.js +159 -0
- package/live/reconciler.d.ts +39 -0
- package/live/reconciler.js +175 -0
- package/live/setup-buckets.d.ts +7 -0
- package/live/setup-buckets.js +33 -0
- package/live/slippage-tracker.d.ts +45 -0
- package/live/slippage-tracker.js +78 -0
- package/live/stop-watcher.d.ts +34 -0
- package/live/stop-watcher.js +158 -0
- package/live/user-data-active-probe.d.ts +54 -0
- package/live/user-data-active-probe.js +180 -0
- package/live/user-data-stream-controller.d.ts +200 -0
- package/live/user-data-stream-controller.js +579 -0
- package/live/user-data-stream-ws.d.ts +22 -0
- package/live/user-data-stream-ws.js +63 -0
- package/live/user-data-stream.d.ts +243 -0
- package/live/user-data-stream.js +704 -0
- package/logger.d.ts +2 -0
- package/logger.js +2 -0
- package/mfe.d.ts +21 -0
- package/mfe.js +68 -0
- package/onboarding/mode-ladder.d.ts +1 -0
- package/onboarding/mode-ladder.js +3 -0
- package/onboarding/runtime.d.ts +71 -0
- package/onboarding/runtime.js +153 -0
- package/openclaw.plugin.json +94 -0
- package/package.json +27 -0
- package/paper-adapter.d.ts +24 -0
- package/paper-adapter.js +91 -0
- package/persistence/state-manager.d.ts +42 -0
- package/persistence/state-manager.js +164 -0
- package/pinned-plan.d.ts +9 -0
- package/pinned-plan.js +23 -0
- package/risk/pre-trade-check.d.ts +38 -0
- package/risk/pre-trade-check.js +345 -0
- package/risk/pre-trade-types.d.ts +60 -0
- package/risk/pre-trade-types.js +3 -0
- package/scripts/assemble.mjs +114 -0
- package/shadow/shadow-tracker.d.ts +36 -0
- package/shadow/shadow-tracker.js +151 -0
- package/shadow/types.d.ts +42 -0
- package/shadow/types.js +20 -0
- package/shared/indicators-extended.d.ts +52 -0
- package/shared/indicators-extended.js +291 -0
- package/shared/indicators.d.ts +15 -0
- package/shared/indicators.js +114 -0
- package/signals/conditions/registry.d.ts +16 -0
- package/signals/conditions/registry.js +1274 -0
- package/signals/conditions/types.d.ts +1 -0
- package/signals/conditions/types.js +4 -0
- package/signals/direction-rules.d.ts +3 -0
- package/signals/direction-rules.js +24 -0
- package/signals/entry-rules.d.ts +6 -0
- package/signals/entry-rules.js +33 -0
- package/signals/serialize-context.d.ts +4 -0
- package/signals/serialize-context.js +39 -0
- package/signals/stop-rules.d.ts +3 -0
- package/signals/stop-rules.js +48 -0
- package/signals/strategy-adapter.d.ts +14 -0
- package/signals/strategy-adapter.js +122 -0
- package/signals/types.d.ts +1 -0
- package/signals/types.js +8 -0
- package/simulator/exchange-simulator.d.ts +93 -0
- package/simulator/exchange-simulator.js +684 -0
- package/simulator/fill-engine.d.ts +53 -0
- package/simulator/fill-engine.js +276 -0
- package/simulator/paper-market-feed.d.ts +26 -0
- package/simulator/paper-market-feed.js +104 -0
- package/simulator/realistic-fills.d.ts +59 -0
- package/simulator/realistic-fills.js +175 -0
- package/simulator/types.d.ts +219 -0
- package/simulator/types.js +43 -0
- package/skills/reefclaw/SKILL.md +97 -0
- package/strategy/builtin-strategies.d.ts +2 -0
- package/strategy/builtin-strategies.js +109 -0
- package/strategy/condition-registry.d.ts +3 -0
- package/strategy/condition-registry.js +153 -0
- package/strategy/evaluator.d.ts +67 -0
- package/strategy/evaluator.js +93 -0
- package/tools/assessment-validation.d.ts +118 -0
- package/tools/assessment-validation.js +415 -0
- package/tools/attach-brackets.d.ts +34 -0
- package/tools/attach-brackets.js +363 -0
- package/tools/audit-bracket-protection.d.ts +49 -0
- package/tools/audit-bracket-protection.js +527 -0
- package/tools/cancel-all-orders.d.ts +7 -0
- package/tools/cancel-all-orders.js +5 -0
- package/tools/cancel-order.d.ts +10 -0
- package/tools/cancel-order.js +14 -0
- package/tools/check-position-health.d.ts +46 -0
- package/tools/check-position-health.js +194 -0
- package/tools/clear-exchange-credentials.d.ts +24 -0
- package/tools/clear-exchange-credentials.js +70 -0
- package/tools/close-position.d.ts +22 -0
- package/tools/close-position.js +449 -0
- package/tools/create-order.d.ts +54 -0
- package/tools/create-order.js +338 -0
- package/tools/exit-gate.d.ts +58 -0
- package/tools/exit-gate.js +162 -0
- package/tools/fetch-balance.d.ts +5 -0
- package/tools/fetch-balance.js +4 -0
- package/tools/fetch-ohlcv.d.ts +11 -0
- package/tools/fetch-ohlcv.js +8 -0
- package/tools/fetch-open-orders.d.ts +7 -0
- package/tools/fetch-open-orders.js +4 -0
- package/tools/fetch-positions.d.ts +7 -0
- package/tools/fetch-positions.js +4 -0
- package/tools/fetch-ticker.d.ts +11 -0
- package/tools/fetch-ticker.js +5 -0
- package/tools/get-agent-profile.d.ts +4 -0
- package/tools/get-agent-profile.js +6 -0
- package/tools/get-analytics.d.ts +6 -0
- package/tools/get-analytics.js +7 -0
- package/tools/get-backtest.d.ts +12 -0
- package/tools/get-backtest.js +91 -0
- package/tools/get-basis.d.ts +7 -0
- package/tools/get-basis.js +7 -0
- package/tools/get-bracket-config.d.ts +11 -0
- package/tools/get-bracket-config.js +24 -0
- package/tools/get-cascade-risk.d.ts +7 -0
- package/tools/get-cascade-risk.js +8 -0
- package/tools/get-crypto-metrics.d.ts +18 -0
- package/tools/get-crypto-metrics.js +45 -0
- package/tools/get-cvd.d.ts +6 -0
- package/tools/get-cvd.js +6 -0
- package/tools/get-divergences.d.ts +6 -0
- package/tools/get-divergences.js +6 -0
- package/tools/get-funding-context.d.ts +6 -0
- package/tools/get-funding-context.js +16 -0
- package/tools/get-liquidation-levels.d.ts +7 -0
- package/tools/get-liquidation-levels.js +7 -0
- package/tools/get-liquidation-pulse.d.ts +9 -0
- package/tools/get-liquidation-pulse.js +22 -0
- package/tools/get-market-breadth.d.ts +6 -0
- package/tools/get-market-breadth.js +8 -0
- package/tools/get-market-intel.d.ts +19 -0
- package/tools/get-market-intel.js +116 -0
- package/tools/get-market-structure.d.ts +47 -0
- package/tools/get-market-structure.js +198 -0
- package/tools/get-my-mined-patterns.d.ts +20 -0
- package/tools/get-my-mined-patterns.js +61 -0
- package/tools/get-my-proposed-learnings.d.ts +20 -0
- package/tools/get-my-proposed-learnings.js +55 -0
- package/tools/get-my-recent-reviews.d.ts +22 -0
- package/tools/get-my-recent-reviews.js +66 -0
- package/tools/get-orderbook.d.ts +21 -0
- package/tools/get-orderbook.js +32 -0
- package/tools/get-pattern-scan.d.ts +7 -0
- package/tools/get-pattern-scan.js +8 -0
- package/tools/get-regime.d.ts +6 -0
- package/tools/get-regime.js +7 -0
- package/tools/get-relevant-learnings.d.ts +21 -0
- package/tools/get-relevant-learnings.js +65 -0
- package/tools/get-resting-liquidity.d.ts +6 -0
- package/tools/get-resting-liquidity.js +11 -0
- package/tools/get-risk-scenario.d.ts +29 -0
- package/tools/get-risk-scenario.js +47 -0
- package/tools/get-risk-summary.d.ts +51 -0
- package/tools/get-risk-summary.js +118 -0
- package/tools/get-sentiment.d.ts +4 -0
- package/tools/get-sentiment.js +6 -0
- package/tools/get-session-review.d.ts +7 -0
- package/tools/get-session-review.js +8 -0
- package/tools/get-setup-detail.d.ts +7 -0
- package/tools/get-setup-detail.js +303 -0
- package/tools/get-signals.d.ts +15 -0
- package/tools/get-signals.js +54 -0
- package/tools/get-sizing.d.ts +6 -0
- package/tools/get-sizing.js +6 -0
- package/tools/get-trade-feedback.d.ts +7 -0
- package/tools/get-trade-feedback.js +8 -0
- package/tools/get-trade-flow.d.ts +7 -0
- package/tools/get-trade-flow.js +7 -0
- package/tools/get-volume-analysis.d.ts +21 -0
- package/tools/get-volume-analysis.js +74 -0
- package/tools/get-volume-profile.d.ts +7 -0
- package/tools/get-volume-profile.js +7 -0
- package/tools/helpers.d.ts +25 -0
- package/tools/helpers.js +38 -0
- package/tools/intel-api.d.ts +14 -0
- package/tools/intel-api.js +52 -0
- package/tools/intel-cache.d.ts +25 -0
- package/tools/intel-cache.js +133 -0
- package/tools/list-strategies.d.ts +7 -0
- package/tools/list-strategies.js +6 -0
- package/tools/modify-stop.d.ts +17 -0
- package/tools/modify-stop.js +81 -0
- package/tools/modify-target.d.ts +17 -0
- package/tools/modify-target.js +71 -0
- package/tools/propose-learning.d.ts +22 -0
- package/tools/propose-learning.js +65 -0
- package/tools/query-review-outcomes.d.ts +30 -0
- package/tools/query-review-outcomes.js +64 -0
- package/tools/query-trades.d.ts +21 -0
- package/tools/query-trades.js +37 -0
- package/tools/record-position-reviews.d.ts +38 -0
- package/tools/record-position-reviews.js +147 -0
- package/tools/save-strategy.d.ts +16 -0
- package/tools/save-strategy.js +46 -0
- package/tools/scan-pairs.d.ts +18 -0
- package/tools/scan-pairs.js +220 -0
- package/tools/score-setup.d.ts +31 -0
- package/tools/score-setup.js +268 -0
- package/tools/set-bracket-requirement.d.ts +18 -0
- package/tools/set-bracket-requirement.js +81 -0
- package/tools/set-exchange-credentials.d.ts +25 -0
- package/tools/set-exchange-credentials.js +80 -0
- package/tools/set-trading-mode.d.ts +26 -0
- package/tools/set-trading-mode.js +135 -0
- package/tools/test-exchange-credentials.d.ts +16 -0
- package/tools/test-exchange-credentials.js +100 -0
- package/tools/toggle-strategy.d.ts +8 -0
- package/tools/toggle-strategy.js +8 -0
- package/trading-params-cache.d.ts +26 -0
- package/trading-params-cache.js +52 -0
- package/types.d.ts +110 -0
- package/types.js +7 -0
- package/util/plugin-paths.d.ts +3 -0
- package/util/plugin-paths.js +15 -0
package/bridge/bridge.js
ADDED
|
@@ -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
|
+
}
|