@reefclaw/openclaw-plugin 0.1.13 → 0.1.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bridge/bridge.d.ts +20 -5
- package/bridge/bridge.js +29 -14
- package/bridge/config.js +6 -0
- package/bridge/gateway/gateway-config.d.ts +16 -5
- package/bridge/gateway/gateway-config.js +68 -12
- package/bridge/gateway/gateway-ws-client.d.ts +4 -1
- package/bridge/gateway/gateway-ws-client.js +41 -11
- package/bridge/gateway/poller.js +18 -8
- package/bridge/providers/emergency-commands.d.ts +9 -1
- package/bridge/providers/emergency-commands.js +38 -1
- package/bridge/providers/gateway.d.ts +51 -1
- package/bridge/providers/gateway.js +209 -22
- package/bridge/providers/onboarding-commands.d.ts +11 -0
- package/bridge/providers/onboarding-commands.js +5 -5
- package/bridge/providers/risk-calculator.d.ts +61 -2
- package/bridge/providers/risk-calculator.js +92 -20
- package/bridge/utils/skill-signing.js +8 -3
- package/ccxt/binance-public.d.ts +17 -5
- package/ccxt/binance-public.js +31 -3
- package/config/operator-provenance.d.ts +6 -0
- package/config/operator-provenance.js +50 -0
- package/config/plugin-config-io.d.ts +15 -1
- package/config/plugin-config-io.js +29 -0
- package/exchange-adapter.d.ts +13 -0
- package/index.js +230 -176
- package/ingest/event-loop-monitor.d.ts +22 -0
- package/ingest/event-loop-monitor.js +190 -0
- package/ingest/position-auto-capture.d.ts +5 -0
- package/ingest/position-auto-capture.js +14 -5
- package/ingest/readiness-reporter.d.ts +26 -6
- package/ingest/readiness-reporter.js +137 -9
- package/ingest/skill-version-reader.d.ts +16 -0
- package/ingest/skill-version-reader.js +64 -0
- package/live/approval-lifecycle.d.ts +30 -0
- package/live/approval-lifecycle.js +80 -0
- package/live/bracket-types.d.ts +9 -0
- package/live/live-adapter.d.ts +0 -1
- package/live/user-data-stream.js +10 -2
- package/onboarding/runtime.d.ts +34 -1
- package/onboarding/runtime.js +56 -5
- package/openclaw.plugin.json +1 -1
- package/package.json +6 -5
- package/risk/pre-trade-check.js +18 -5
- package/simulator/exchange-simulator.d.ts +45 -2
- package/simulator/exchange-simulator.js +96 -4
- package/simulator/types.d.ts +17 -0
- package/skills/reefclaw/SKILL.md +6 -11
- package/strategy/condition-registry.js +9 -2
- package/strategy/evaluator.d.ts +5 -0
- package/tools/attach-brackets.js +50 -1
- package/tools/cancel-all-orders.js +9 -1
- package/tools/create-order.js +18 -1
- package/tools/get-bracket-config.d.ts +21 -2
- package/tools/get-bracket-config.js +18 -2
- package/tools/set-trading-mode.js +6 -3
- package/venues/hyperliquid/hl-bracket-coordinator.d.ts +25 -1
- package/venues/hyperliquid/hl-bracket-coordinator.js +57 -0
- package/venues/hyperliquid/hl-brackets.d.ts +10 -0
- package/venues/hyperliquid/hl-brackets.js +45 -13
- package/venues/hyperliquid/hl-fill-ingest.d.ts +18 -0
- package/venues/hyperliquid/hl-fill-ingest.js +88 -0
- package/venues/hyperliquid/hl-live-adapter.d.ts +36 -0
- package/venues/hyperliquid/hl-live-adapter.js +116 -7
- package/venues/hyperliquid/hl-public.d.ts +12 -5
- package/venues/hyperliquid/hl-public.js +24 -3
- package/venues/hyperliquid/hl-user-stream.d.ts +13 -1
- package/venues/hyperliquid/hl-user-stream.js +4 -1
- package/venues/registry.js +8 -7
- package/wave9/paper-admission-guard.d.ts +12 -1
- package/wave9/paper-admission-guard.js +12 -1
- package/scripts/assemble.mjs +0 -130
package/bridge/bridge.d.ts
CHANGED
|
@@ -66,11 +66,26 @@ export declare class Bridge {
|
|
|
66
66
|
private stopThrottleTimer;
|
|
67
67
|
private handleRequest;
|
|
68
68
|
private handleStateChange;
|
|
69
|
-
/**
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
|
|
69
|
+
/** Whether operator-write methods are allowed for the current session mix.
|
|
70
|
+
*
|
|
71
|
+
* This is NOT a per-frame credential check, and does not claim to be. The
|
|
72
|
+
* real authorization boundary for operator-write methods is layered
|
|
73
|
+
* OUTSIDE this method:
|
|
74
|
+
* 1. Transport: every frame reaching handleRequest arrives over the relay
|
|
75
|
+
* connection, which authenticated the per-user rc_ token against the
|
|
76
|
+
* user-bound relay room before any frame flows (relay/src/auth.ts).
|
|
77
|
+
* There is no other ingress to this dispatcher.
|
|
78
|
+
* 2. State-mutating tools: the plugin independently refuses
|
|
79
|
+
* set_trading_mode / set_exchange_credentials /
|
|
80
|
+
* clear_exchange_credentials / set_bracket_requirement /
|
|
81
|
+
* test_exchange_credentials without the `operator_token` provenance
|
|
82
|
+
* proof (audit F12) — a compromised bridge still cannot flip trading
|
|
83
|
+
* state.
|
|
84
|
+
* While every relay session is an operator session by construction, this
|
|
85
|
+
* returns true; it is the seam where per-session roles attach if a
|
|
86
|
+
* non-operator ingress is ever added, without restructuring the
|
|
87
|
+
* dispatcher. */
|
|
88
|
+
private operatorWriteAllowed;
|
|
74
89
|
/** Handle a set_trading_mode request. Returns 'responded' if the handler
|
|
75
90
|
* already wrote the response frame (e.g. on validation failure), otherwise
|
|
76
91
|
* returns the structured outcome for the caller to wrap in a success
|
package/bridge/bridge.js
CHANGED
|
@@ -326,15 +326,15 @@ export class Bridge {
|
|
|
326
326
|
async handleRequest(frame) {
|
|
327
327
|
const { id, method, params } = frame;
|
|
328
328
|
try {
|
|
329
|
-
// ---- Operator-write
|
|
330
|
-
// Methods in OPERATOR_WRITE_METHODS
|
|
331
|
-
//
|
|
332
|
-
//
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
//
|
|
336
|
-
// list
|
|
337
|
-
if (OPERATOR_WRITE_METHODS.has(method) && !this.
|
|
329
|
+
// ---- Operator-write gate ----
|
|
330
|
+
// Methods in OPERATOR_WRITE_METHODS are dashboard-operator actions. The
|
|
331
|
+
// enforcement is layered and documented on operatorWriteAllowed():
|
|
332
|
+
// transport auth at the relay (token↔room binding — the only ingress to
|
|
333
|
+
// this dispatcher) + plugin-side operator_token provenance on every
|
|
334
|
+
// state-mutating tool. This branch is the forward-compatible seam for
|
|
335
|
+
// per-session roles; it does not itself verify credentials. Emergency
|
|
336
|
+
// methods are on the list for the same forward-compatibility reason.
|
|
337
|
+
if (OPERATOR_WRITE_METHODS.has(method) && !this.operatorWriteAllowed()) {
|
|
338
338
|
audit('operator_write.denied', { method, id });
|
|
339
339
|
this.connector.sendResponse(id, false, undefined, {
|
|
340
340
|
code: 403,
|
|
@@ -578,11 +578,26 @@ export class Bridge {
|
|
|
578
578
|
logger.info(TAG, `Connector state: ${state}`);
|
|
579
579
|
}
|
|
580
580
|
// ---- Operator onboarding (PR2) ----
|
|
581
|
-
/**
|
|
582
|
-
*
|
|
583
|
-
*
|
|
584
|
-
*
|
|
585
|
-
|
|
581
|
+
/** Whether operator-write methods are allowed for the current session mix.
|
|
582
|
+
*
|
|
583
|
+
* This is NOT a per-frame credential check, and does not claim to be. The
|
|
584
|
+
* real authorization boundary for operator-write methods is layered
|
|
585
|
+
* OUTSIDE this method:
|
|
586
|
+
* 1. Transport: every frame reaching handleRequest arrives over the relay
|
|
587
|
+
* connection, which authenticated the per-user rc_ token against the
|
|
588
|
+
* user-bound relay room before any frame flows (relay/src/auth.ts).
|
|
589
|
+
* There is no other ingress to this dispatcher.
|
|
590
|
+
* 2. State-mutating tools: the plugin independently refuses
|
|
591
|
+
* set_trading_mode / set_exchange_credentials /
|
|
592
|
+
* clear_exchange_credentials / set_bracket_requirement /
|
|
593
|
+
* test_exchange_credentials without the `operator_token` provenance
|
|
594
|
+
* proof (audit F12) — a compromised bridge still cannot flip trading
|
|
595
|
+
* state.
|
|
596
|
+
* While every relay session is an operator session by construction, this
|
|
597
|
+
* returns true; it is the seam where per-session roles attach if a
|
|
598
|
+
* non-operator ingress is ever added, without restructuring the
|
|
599
|
+
* dispatcher. */
|
|
600
|
+
operatorWriteAllowed() {
|
|
586
601
|
return true;
|
|
587
602
|
}
|
|
588
603
|
/** Handle a set_trading_mode request. Returns 'responded' if the handler
|
package/bridge/config.js
CHANGED
|
@@ -11,6 +11,12 @@ const OPENCLAW_DIR = join(homedir(), '.openclaw');
|
|
|
11
11
|
const CONFIG_PATH = join(OPENCLAW_DIR, 'openclaw.json');
|
|
12
12
|
const DEFAULT_RELAY_URL = 'wss://reefclaw.radunlupsa.partykit.dev';
|
|
13
13
|
// ---- Read/write OpenClaw config ----
|
|
14
|
+
//
|
|
15
|
+
// Scope contract: these helpers touch ONLY the ReefClaw skill entry
|
|
16
|
+
// (skills.entries.reefclaw.config — the token/userId/relayUrl the USER pasted
|
|
17
|
+
// during onboarding) and preserve every other key verbatim. They exist so the
|
|
18
|
+
// connector can pick up a connection the user saved via chat and persist it
|
|
19
|
+
// across restarts — not to manage OpenClaw's own configuration.
|
|
14
20
|
/** Read the OpenClaw config file and extract ReefClaw skill settings.
|
|
15
21
|
* Prefers the schema-valid nested shape (entry.config.*), falls back to the
|
|
16
22
|
* legacy flat shape per-field. */
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type VenueId } from '@reefclaw/shared';
|
|
1
2
|
export interface GatewayConfig {
|
|
2
3
|
/** Full gateway URL, e.g. "http://localhost:18789" */
|
|
3
4
|
gatewayUrl: string;
|
|
@@ -5,10 +6,16 @@ export interface GatewayConfig {
|
|
|
5
6
|
gatewayToken: string;
|
|
6
7
|
/** Trading symbol, e.g. "BTC/USDT" */
|
|
7
8
|
symbol: string;
|
|
8
|
-
/** Intelligence service URL (
|
|
9
|
+
/** Intelligence service URL (regime classification + trade-result reporting) */
|
|
9
10
|
intelligenceUrl?: string;
|
|
10
11
|
/** Connection token for intelligence service auth */
|
|
11
12
|
connectionToken?: string;
|
|
13
|
+
/** Trading venue this box is configured for. Decides how a canonical symbol
|
|
14
|
+
* is spelled in the intel DB ('BTC/USDT' → 'BTCUSDT' vs 'BTC/USDC' →
|
|
15
|
+
* 'HL_BTC'), so every skill↔intel symbol crossing depends on it.
|
|
16
|
+
* Optional so a hand-built config (tests, embedders) stays valid; every
|
|
17
|
+
* consumer treats absent as 'binance', the historical behaviour. */
|
|
18
|
+
venue?: VenueId;
|
|
12
19
|
}
|
|
13
20
|
/** CLI args specific to gateway provider */
|
|
14
21
|
export interface GatewayCliArgs {
|
|
@@ -20,17 +27,21 @@ export interface GatewayCliArgs {
|
|
|
20
27
|
/**
|
|
21
28
|
* Resolve gateway connection config with priority:
|
|
22
29
|
* 1. CLI args (--gateway-url, --gateway-token, --symbol)
|
|
23
|
-
* 2. Env vars (OPENCLAW_GATEWAY_URL, OPENCLAW_GATEWAY_TOKEN, REEFCLAW_SYMBOL
|
|
24
|
-
*
|
|
25
|
-
*
|
|
30
|
+
* 2. Env vars (OPENCLAW_GATEWAY_URL, OPENCLAW_GATEWAY_TOKEN, REEFCLAW_SYMBOL,
|
|
31
|
+
* REEFCLAW_INTELLIGENCE_URL, REEFCLAW_VENUE)
|
|
32
|
+
* 3. Config files (~/.openclaw/openclaw.json, ~/.reefclaw/plugin-config.json)
|
|
33
|
+
* 4. Defaults (localhost:18789, the venue's default symbol, intel.reefclaw.com)
|
|
26
34
|
*
|
|
27
35
|
* Returns null for gatewayToken if it can't be resolved (caller must handle).
|
|
36
|
+
* `intelligenceUrl` is never null — see DEFAULT_INTELLIGENCE_URL for why that
|
|
37
|
+
* matters.
|
|
28
38
|
*/
|
|
29
39
|
export declare function resolveGatewayConfig(args: GatewayCliArgs): {
|
|
30
40
|
gatewayUrl: string;
|
|
31
41
|
gatewayToken: string | null;
|
|
32
42
|
symbol: string;
|
|
33
|
-
intelligenceUrl: string
|
|
43
|
+
intelligenceUrl: string;
|
|
44
|
+
venue: VenueId;
|
|
34
45
|
};
|
|
35
46
|
/**
|
|
36
47
|
* Validate that the resolved config has all required fields.
|
|
@@ -5,11 +5,27 @@ import { readFileSync } from 'fs';
|
|
|
5
5
|
import { join } from 'path';
|
|
6
6
|
import { homedir } from 'os';
|
|
7
7
|
import JSON5 from 'json5';
|
|
8
|
+
import { parseVenue, VENUE_DEFAULT_SYMBOL } from '@reefclaw/shared';
|
|
8
9
|
import { logger } from '../logger.js';
|
|
9
10
|
const TAG = 'gateway-config';
|
|
10
11
|
const DEFAULT_GATEWAY_PORT = 18789;
|
|
11
12
|
const DEFAULT_GATEWAY_HOST = 'localhost';
|
|
12
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Same default the plugin hardcodes (plugin/src/index.ts) so the two processes
|
|
15
|
+
* agree on the intel service without any operator configuration.
|
|
16
|
+
*
|
|
17
|
+
* ★ Before 2026-07-27 the skill had NO default and NO config-file fallback:
|
|
18
|
+
* `intelligenceUrl` came only from `--intelligence-url` or
|
|
19
|
+
* `REEFCLAW_INTELLIGENCE_URL`, and NOTHING in the repo ever set either one —
|
|
20
|
+
* not the installer, not a systemd unit, not a deploy script. The skill is the
|
|
21
|
+
* ONLY writer of intel's `trade_results` table, so on every default install
|
|
22
|
+
* every closed trade was dropped at debug level inside `reportTradeResult`.
|
|
23
|
+
* Downstream that meant `tradeCount` never reached `minTradesForKelly`, Kelly
|
|
24
|
+
* sizing was pinned at `cappedReason: 'insufficient_history'` forever, and
|
|
25
|
+
* `get_agent_profile` reported tier `novice` / 0 trades no matter how much the
|
|
26
|
+
* agent traded. Reads had a default; the write did not.
|
|
27
|
+
*/
|
|
28
|
+
const DEFAULT_INTELLIGENCE_URL = 'https://intel.reefclaw.com';
|
|
13
29
|
// ---- Read OpenClaw config file ----
|
|
14
30
|
function getConfigPath() {
|
|
15
31
|
return process.env.OPENCLAW_CONFIG_PATH
|
|
@@ -28,18 +44,48 @@ function readOpenClawJson() {
|
|
|
28
44
|
return null;
|
|
29
45
|
}
|
|
30
46
|
}
|
|
47
|
+
// ---- Read the plugin's own config file ----
|
|
48
|
+
function getPluginConfigPath() {
|
|
49
|
+
return process.env.REEFCLAW_PLUGIN_CONFIG_PATH
|
|
50
|
+
|| join(homedir(), '.reefclaw', 'plugin-config.json');
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Best-effort read of ~/.reefclaw/plugin-config.json — the file that already
|
|
54
|
+
* carries `intelligenceUrl` and `exchange.venue` for the plugin. Reading the
|
|
55
|
+
* same file here makes the two processes agree by construction rather than by
|
|
56
|
+
* convention (the skill previously had no way at all to learn either value).
|
|
57
|
+
* Fail-soft: null on a missing/unreadable/malformed file, which is the common
|
|
58
|
+
* case on a chat-install box that never writes this file.
|
|
59
|
+
*/
|
|
60
|
+
function readPluginConfig() {
|
|
61
|
+
const path = getPluginConfigPath();
|
|
62
|
+
try {
|
|
63
|
+
const parsed = JSON.parse(readFileSync(path, 'utf-8'));
|
|
64
|
+
if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
65
|
+
return null;
|
|
66
|
+
return parsed;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
logger.debug(TAG, `Could not read plugin config from ${path}`);
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
31
73
|
// ---- Resolve gateway config ----
|
|
32
74
|
/**
|
|
33
75
|
* Resolve gateway connection config with priority:
|
|
34
76
|
* 1. CLI args (--gateway-url, --gateway-token, --symbol)
|
|
35
|
-
* 2. Env vars (OPENCLAW_GATEWAY_URL, OPENCLAW_GATEWAY_TOKEN, REEFCLAW_SYMBOL
|
|
36
|
-
*
|
|
37
|
-
*
|
|
77
|
+
* 2. Env vars (OPENCLAW_GATEWAY_URL, OPENCLAW_GATEWAY_TOKEN, REEFCLAW_SYMBOL,
|
|
78
|
+
* REEFCLAW_INTELLIGENCE_URL, REEFCLAW_VENUE)
|
|
79
|
+
* 3. Config files (~/.openclaw/openclaw.json, ~/.reefclaw/plugin-config.json)
|
|
80
|
+
* 4. Defaults (localhost:18789, the venue's default symbol, intel.reefclaw.com)
|
|
38
81
|
*
|
|
39
82
|
* Returns null for gatewayToken if it can't be resolved (caller must handle).
|
|
83
|
+
* `intelligenceUrl` is never null — see DEFAULT_INTELLIGENCE_URL for why that
|
|
84
|
+
* matters.
|
|
40
85
|
*/
|
|
41
86
|
export function resolveGatewayConfig(args) {
|
|
42
87
|
const ocConfig = readOpenClawJson();
|
|
88
|
+
const pluginConfig = readPluginConfig();
|
|
43
89
|
// --- Gateway URL ---
|
|
44
90
|
// CLI > env > construct from openclaw.json port > default
|
|
45
91
|
const port = ocConfig?.gateway?.port ?? DEFAULT_GATEWAY_PORT;
|
|
@@ -56,24 +102,34 @@ export function resolveGatewayConfig(args) {
|
|
|
56
102
|
if (fileToken && !args.gatewayToken && !process.env.OPENCLAW_GATEWAY_TOKEN) {
|
|
57
103
|
logger.info(TAG, 'Using gateway token from OpenClaw config');
|
|
58
104
|
}
|
|
105
|
+
// --- Venue ---
|
|
106
|
+
// env > plugin-config.json exchange.venue > binance. Resolved BEFORE the
|
|
107
|
+
// symbol because it decides both the symbol default and the intel spelling.
|
|
108
|
+
// parseVenue surfaces a mis-spelled value instead of silently coercing it.
|
|
109
|
+
const { venue, unrecognized } = parseVenue(process.env.REEFCLAW_VENUE ?? pluginConfig?.exchange?.venue);
|
|
110
|
+
if (unrecognized) {
|
|
111
|
+
logger.warn(TAG, `Unrecognized venue '${unrecognized}' — falling back to binance`);
|
|
112
|
+
}
|
|
59
113
|
// --- Symbol ---
|
|
60
|
-
// CLI > env > default
|
|
114
|
+
// CLI > env > the VENUE's default. A venue-blind 'BTC/USDT' default on a
|
|
115
|
+
// hyperliquid box is a symbol that venue rejects everywhere it's consumed
|
|
116
|
+
// (the issue-#174 class of bug).
|
|
61
117
|
const symbol = args.symbol
|
|
62
118
|
|| process.env.REEFCLAW_SYMBOL
|
|
63
|
-
||
|
|
119
|
+
|| VENUE_DEFAULT_SYMBOL[venue];
|
|
64
120
|
if (!gatewayToken) {
|
|
65
121
|
logger.warn(TAG, 'Gateway token not found in CLI args, env vars, or OpenClaw config');
|
|
66
122
|
}
|
|
67
123
|
// --- Intelligence URL ---
|
|
68
|
-
// CLI > env > default
|
|
124
|
+
// CLI > env > plugin-config.json > default. A default is mandatory: this URL
|
|
125
|
+
// gates the ONLY writer of intel's trade_results (see the constant).
|
|
69
126
|
const intelligenceUrl = args.intelligenceUrl
|
|
70
127
|
|| process.env.REEFCLAW_INTELLIGENCE_URL
|
|
71
|
-
||
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
}
|
|
128
|
+
|| pluginConfig?.intelligenceUrl
|
|
129
|
+
|| DEFAULT_INTELLIGENCE_URL;
|
|
130
|
+
logger.info(TAG, `Intelligence service: ${intelligenceUrl} (venue=${venue})`);
|
|
75
131
|
logger.debug(TAG, `Resolved: url=${gatewayUrl}, token=${gatewayToken ? '***' : '(none)'}, symbol=${symbol}`);
|
|
76
|
-
return { gatewayUrl, gatewayToken, symbol, intelligenceUrl };
|
|
132
|
+
return { gatewayUrl, gatewayToken, symbol, intelligenceUrl, venue };
|
|
77
133
|
}
|
|
78
134
|
/**
|
|
79
135
|
* Validate that the resolved config has all required fields.
|
|
@@ -71,7 +71,10 @@ export declare class GatewayWsClient {
|
|
|
71
71
|
private listeners;
|
|
72
72
|
private readonly gatewayUrl;
|
|
73
73
|
private readonly gatewayToken;
|
|
74
|
-
|
|
74
|
+
private readonly requestAdminScope;
|
|
75
|
+
constructor(config: Pick<GatewayConfig, 'gatewayUrl' | 'gatewayToken'> & {
|
|
76
|
+
requestAdminScope?: boolean;
|
|
77
|
+
}, reconnect?: Partial<ReconnectConfig>);
|
|
75
78
|
/** Start the WebSocket connection and handshake */
|
|
76
79
|
connect(): void;
|
|
77
80
|
/** Disconnect and clean up. Cannot reconnect after this. */
|
|
@@ -5,6 +5,21 @@ import WebSocket from 'ws';
|
|
|
5
5
|
import { logger } from '../logger.js';
|
|
6
6
|
import { computeDelay } from '../utils/reconnect.js';
|
|
7
7
|
const TAG = 'gateway-ws';
|
|
8
|
+
/** True when a `host[:port][/path]` string points at this machine. Used to
|
|
9
|
+
* decide the default scheme for bare gateway hosts (loopback → ws://, remote
|
|
10
|
+
* → wss://) and to warn on plaintext-to-remote. */
|
|
11
|
+
function isLoopbackHost(hostAndRest) {
|
|
12
|
+
const authority = hostAndRest.split('/')[0].toLowerCase();
|
|
13
|
+
if (authority.startsWith('[')) {
|
|
14
|
+
// Bracketed IPv6, e.g. [::1]:18789
|
|
15
|
+
const end = authority.indexOf(']');
|
|
16
|
+
return (end > 0 ? authority.slice(1, end) : authority.slice(1)) === '::1';
|
|
17
|
+
}
|
|
18
|
+
if (authority === '::1')
|
|
19
|
+
return true; // bare IPv6 loopback (no port possible)
|
|
20
|
+
const host = authority.split(':')[0];
|
|
21
|
+
return host === 'localhost' || host.startsWith('127.');
|
|
22
|
+
}
|
|
8
23
|
// ---- Protocol constants ----
|
|
9
24
|
// Protocol range we can speak. The gateway picks its own version if it falls
|
|
10
25
|
// inside [min, max]. v3 = OpenClaw ≤2026.4.x (prod); v4 = OpenClaw 2026.6+
|
|
@@ -45,8 +60,14 @@ export class GatewayWsClient {
|
|
|
45
60
|
listeners = new Map();
|
|
46
61
|
gatewayUrl;
|
|
47
62
|
gatewayToken;
|
|
63
|
+
requestAdminScope;
|
|
48
64
|
constructor(config, reconnect) {
|
|
49
|
-
|
|
65
|
+
this.requestAdminScope = config.requestAdminScope === true;
|
|
66
|
+
// Convert to WebSocket URL: http→ws, https→wss. A bare host:port defaults
|
|
67
|
+
// by destination: loopback → ws:// (the normal localhost gateway), anything
|
|
68
|
+
// else → wss:// — a remote default must never silently downgrade to
|
|
69
|
+
// plaintext, because the hello frame carries the gateway token. An explicit
|
|
70
|
+
// ws:// to a remote host is honored but warned about below.
|
|
50
71
|
let url = config.gatewayUrl.replace(/\/$/, '');
|
|
51
72
|
if (/^https:\/\//.test(url)) {
|
|
52
73
|
url = url.replace(/^https:\/\//, 'wss://');
|
|
@@ -55,7 +76,12 @@ export class GatewayWsClient {
|
|
|
55
76
|
url = url.replace(/^http:\/\//, 'ws://');
|
|
56
77
|
}
|
|
57
78
|
else if (!/^wss?:\/\//.test(url)) {
|
|
58
|
-
url =
|
|
79
|
+
url = `${isLoopbackHost(url) ? 'ws' : 'wss'}://${url}`;
|
|
80
|
+
}
|
|
81
|
+
if (/^ws:\/\//.test(url) && !isLoopbackHost(url.slice('ws://'.length))) {
|
|
82
|
+
logger.warn(TAG, `Gateway URL ${url} is PLAINTEXT ws:// to a non-loopback host — the ` +
|
|
83
|
+
`gateway token will transit unencrypted. Use wss:// (or an SSH tunnel ` +
|
|
84
|
+
`to localhost) unless this network is fully trusted.`);
|
|
59
85
|
}
|
|
60
86
|
this.gatewayUrl = url;
|
|
61
87
|
this.gatewayToken = config.gatewayToken;
|
|
@@ -280,15 +306,19 @@ export class GatewayWsClient {
|
|
|
280
306
|
mode: 'backend',
|
|
281
307
|
},
|
|
282
308
|
role: 'operator',
|
|
283
|
-
// operator.admin
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
// 2026-07-22
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
//
|
|
291
|
-
scopes: [
|
|
309
|
+
// Least privilege: operator.admin is requested ONLY while the
|
|
310
|
+
// once-per-install heartbeat-cron ensure is still owed (cron.list/
|
|
311
|
+
// cron.add moved behind the admin scope on OpenClaw 2026.7.x —
|
|
312
|
+
// "Heartbeat cron: skipped (missing scope: operator.admin)", observed
|
|
313
|
+
// 2026-07-22 on openclaw 2026.7.1-2; without it fresh installs never
|
|
314
|
+
// get a heartbeat cron). Once the ensure marker exists, the caller
|
|
315
|
+
// constructs this client with requestAdminScope=false and every later
|
|
316
|
+
// session runs on read/write only.
|
|
317
|
+
scopes: [
|
|
318
|
+
'operator.read',
|
|
319
|
+
'operator.write',
|
|
320
|
+
...(this.requestAdminScope ? ['operator.admin'] : []),
|
|
321
|
+
],
|
|
292
322
|
auth: {
|
|
293
323
|
token: this.gatewayToken,
|
|
294
324
|
...(this.deviceToken && { deviceToken: this.deviceToken }),
|
package/bridge/gateway/poller.js
CHANGED
|
@@ -356,8 +356,14 @@ export class Poller {
|
|
|
356
356
|
const tool = this.toolMap.fetch_positions;
|
|
357
357
|
// Fetch ALL positions (no symbol filter) so multi-symbol trades are visible
|
|
358
358
|
const result = await this.http.invoke(tool, {});
|
|
359
|
-
|
|
360
|
-
|
|
359
|
+
if (!Array.isArray(result.data)) {
|
|
360
|
+
// null ≠ empty: this cache is the emergency-Flatten fallback. Collapsing
|
|
361
|
+
// a garbage response to [] would let a later failed fresh read report
|
|
362
|
+
// "No positions to close" off poisoned data (audit 2026-07-26 F6).
|
|
363
|
+
logger.warn(TAG, `fetch_positions returned non-array (${typeof result.data}) — keeping last known positions`);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
this.callbacks.onPositions(result.data);
|
|
361
367
|
}
|
|
362
368
|
async pollBalance() {
|
|
363
369
|
const tool = this.toolMap.fetch_balance;
|
|
@@ -370,13 +376,17 @@ export class Poller {
|
|
|
370
376
|
// Fetch ALL open orders (no symbol filter) — this cache is the fallback
|
|
371
377
|
// for the portfolio-wide Kill + reconcile snapshot (see interval comment).
|
|
372
378
|
const result = await this.http.invoke(tool, {});
|
|
379
|
+
if (!Array.isArray(result.data)) {
|
|
380
|
+
// null ≠ empty: same reasoning as pollPositions — a garbage response
|
|
381
|
+
// must not overwrite the Kill fallback cache with a confirmed-empty [].
|
|
382
|
+
logger.warn(TAG, `fetch_open_orders returned non-array (${typeof result.data}) — keeping last known orders`);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
373
385
|
const orders = [];
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
orders.push(mapped);
|
|
379
|
-
}
|
|
386
|
+
for (const ccxt of result.data) {
|
|
387
|
+
const mapped = mapCcxtOrder(ccxt);
|
|
388
|
+
if (mapped)
|
|
389
|
+
orders.push(mapped);
|
|
380
390
|
}
|
|
381
391
|
this.callbacks.onOpenOrders(orders);
|
|
382
392
|
}
|
|
@@ -29,8 +29,16 @@ export interface EmergencyContext {
|
|
|
29
29
|
*
|
|
30
30
|
* Caller must set agentMode = 'STOPPED' after this returns, and should pass a
|
|
31
31
|
* FRESH all-symbols order snapshot (see the gateway wrapper).
|
|
32
|
+
*
|
|
33
|
+
* ★ null ≠ empty (audit 2026-07-26 F6): `freshOrders === null` means the fresh
|
|
34
|
+
* fetch FAILED — order state is unknown. A stale (≤60s) cache that happens to
|
|
35
|
+
* be empty is NOT proof of zero working orders, and reporting executed:true
|
|
36
|
+
* from it is exactly the false-green the 2026-06-19 fix exists to prevent. On
|
|
37
|
+
* a failed fresh read Kill still halts the agent and best-effort cancels the
|
|
38
|
+
* cached-known working set, but ALWAYS reports executed:false with the
|
|
39
|
+
* unverified-coverage reason.
|
|
32
40
|
*/
|
|
33
|
-
export declare function executeKill(ctx: EmergencyContext,
|
|
41
|
+
export declare function executeKill(ctx: EmergencyContext, freshOrders: OrderData[] | null, cachedOrders?: OrderData[]): Promise<EmergencyResult>;
|
|
34
42
|
/**
|
|
35
43
|
* Flatten: close all open positions at market.
|
|
36
44
|
* Caller must set agentMode = 'STOPPED' after this returns.
|
|
@@ -30,11 +30,21 @@ function rejectionReasons(results, max = 3) {
|
|
|
30
30
|
*
|
|
31
31
|
* Caller must set agentMode = 'STOPPED' after this returns, and should pass a
|
|
32
32
|
* FRESH all-symbols order snapshot (see the gateway wrapper).
|
|
33
|
+
*
|
|
34
|
+
* ★ null ≠ empty (audit 2026-07-26 F6): `freshOrders === null` means the fresh
|
|
35
|
+
* fetch FAILED — order state is unknown. A stale (≤60s) cache that happens to
|
|
36
|
+
* be empty is NOT proof of zero working orders, and reporting executed:true
|
|
37
|
+
* from it is exactly the false-green the 2026-06-19 fix exists to prevent. On
|
|
38
|
+
* a failed fresh read Kill still halts the agent and best-effort cancels the
|
|
39
|
+
* cached-known working set, but ALWAYS reports executed:false with the
|
|
40
|
+
* unverified-coverage reason.
|
|
33
41
|
*/
|
|
34
|
-
export async function executeKill(ctx,
|
|
42
|
+
export async function executeKill(ctx, freshOrders, cachedOrders = []) {
|
|
35
43
|
if (!ctx.http) {
|
|
36
44
|
return { action: 'kill', executed: false, timestamp: new Date().toISOString(), details: 'HTTP client not initialized' };
|
|
37
45
|
}
|
|
46
|
+
const fresh = freshOrders !== null;
|
|
47
|
+
const openOrders = freshOrders ?? cachedOrders;
|
|
38
48
|
// Protective = a stop/TP bracket (reduceOnly OR closePosition OR stop-type;
|
|
39
49
|
// see mapCcxtOrder/isProtectiveOrder). Working = everything else (entries /
|
|
40
50
|
// adds). Preserve protective so an open position is never left naked.
|
|
@@ -95,6 +105,15 @@ export async function executeKill(ctx, openOrders) {
|
|
|
95
105
|
logger.warn(TAG, details);
|
|
96
106
|
}
|
|
97
107
|
}
|
|
108
|
+
if (!fresh) {
|
|
109
|
+
// Coverage is unverifiable: whatever the cached list said, orders placed
|
|
110
|
+
// in the last ≤60s are invisible to it. Never render a green "Killed".
|
|
111
|
+
executed = false;
|
|
112
|
+
details = working.length === 0
|
|
113
|
+
? `Agent halted, but order state is UNVERIFIED — fresh order fetch failed and the cached snapshot shows no working orders; verify on the exchange${preservedNote}`
|
|
114
|
+
: `${details} — STALE snapshot (fresh order fetch failed); full coverage unverified`;
|
|
115
|
+
logger.error(TAG, details);
|
|
116
|
+
}
|
|
98
117
|
return { action: 'kill', executed, timestamp: new Date().toISOString(), details };
|
|
99
118
|
}
|
|
100
119
|
/**
|
|
@@ -110,13 +129,19 @@ export async function executeFlatten(ctx, cachedPositions) {
|
|
|
110
129
|
// ctx.symbol here narrowed the result to a single symbol, so positions on
|
|
111
130
|
// every other symbol were silently skipped and never closed. The cached
|
|
112
131
|
// fallback (this.positions) is already all-symbols.
|
|
132
|
+
// ★ null ≠ empty (audit 2026-07-26 F6): `fresh` records whether the list is
|
|
133
|
+
// exchange-confirmed. A stale cache that happens to be empty must NEVER
|
|
134
|
+
// produce a green "Flattened" — a position opened in the last ≤60s would be
|
|
135
|
+
// invisible to it and left running behind a STOPPED agent.
|
|
113
136
|
let positions = cachedPositions;
|
|
137
|
+
let fresh = false;
|
|
114
138
|
const fetchTool = ctx.toolMap.fetch_positions;
|
|
115
139
|
if (fetchTool) {
|
|
116
140
|
try {
|
|
117
141
|
const result = await ctx.http.invokeEmergency(fetchTool, {});
|
|
118
142
|
if (Array.isArray(result.data)) {
|
|
119
143
|
positions = result.data;
|
|
144
|
+
fresh = true;
|
|
120
145
|
}
|
|
121
146
|
else {
|
|
122
147
|
// Mirror gateway.ts fetchFreshPositions: a non-array response must
|
|
@@ -132,6 +157,11 @@ export async function executeFlatten(ctx, cachedPositions) {
|
|
|
132
157
|
// Filter to non-zero positions
|
|
133
158
|
const openPositions = positions.filter((p) => (p.contracts ?? 0) !== 0);
|
|
134
159
|
if (openPositions.length === 0) {
|
|
160
|
+
if (!fresh) {
|
|
161
|
+
const details = 'Position state UNVERIFIED — fresh position fetch failed and the cached snapshot shows none; nothing was closed, verify on the exchange';
|
|
162
|
+
logger.error(TAG, details);
|
|
163
|
+
return { action: 'flatten', executed: false, timestamp: new Date().toISOString(), details };
|
|
164
|
+
}
|
|
135
165
|
return { action: 'flatten', executed: true, timestamp: new Date().toISOString(), details: 'No positions to close' };
|
|
136
166
|
}
|
|
137
167
|
// Close each position
|
|
@@ -179,6 +209,13 @@ export async function executeFlatten(ctx, cachedPositions) {
|
|
|
179
209
|
logger.error(TAG, details);
|
|
180
210
|
return { action: 'flatten', executed: false, timestamp: new Date().toISOString(), details };
|
|
181
211
|
}
|
|
212
|
+
if (!fresh) {
|
|
213
|
+
// Every cached-known position closed, but positions opened in the last
|
|
214
|
+
// ≤60s are invisible to a stale snapshot — coverage is unverifiable.
|
|
215
|
+
const details = `Closed ${succeeded}/${openPositions.length} positions from a STALE snapshot (fresh position fetch failed) — full coverage unverified, verify on the exchange`;
|
|
216
|
+
logger.error(TAG, details);
|
|
217
|
+
return { action: 'flatten', executed: false, timestamp: new Date().toISOString(), details };
|
|
218
|
+
}
|
|
182
219
|
const details = `Closed ${succeeded}/${openPositions.length} positions`;
|
|
183
220
|
return { action: 'flatten', executed: true, timestamp: new Date().toISOString(), details };
|
|
184
221
|
}
|
|
@@ -100,6 +100,7 @@ export declare class GatewayProvider implements OpenClawProvider {
|
|
|
100
100
|
private agentStateInterval;
|
|
101
101
|
private toolRetryInterval;
|
|
102
102
|
private regimeInterval;
|
|
103
|
+
private tradingParamsInterval;
|
|
103
104
|
private signalInterval;
|
|
104
105
|
private missionInterval;
|
|
105
106
|
private analyticsInterval;
|
|
@@ -114,6 +115,17 @@ export declare class GatewayProvider implements OpenClawProvider {
|
|
|
114
115
|
private atrData;
|
|
115
116
|
private atrSampleCount;
|
|
116
117
|
private currentRegime;
|
|
118
|
+
/** ★ The tenant's ENFORCED risk limits, mirrored from intel
|
|
119
|
+
* `/api/trading-params` — the same row the plugin's `preTradeRiskCheck`
|
|
120
|
+
* rejects orders against. Null until the first fetch lands (then
|
|
121
|
+
* DEFAULT_RISK_LIMITS applies). Before this existed the dashboard's risk
|
|
122
|
+
* banner ran on a hardcoded ladder that no enforcer used, so it could show
|
|
123
|
+
* a red "Gate fail" for a limit nothing blocked on — and, in the other
|
|
124
|
+
* direction, stay green while the plugin rejected every entry. */
|
|
125
|
+
private riskLimits;
|
|
126
|
+
/** Tenant drawdown-zone boundaries. Drives the RED-zone auto-flatten, so a
|
|
127
|
+
* stale/stricter local ladder here can flatten a live book early. */
|
|
128
|
+
private drawdownThresholds;
|
|
117
129
|
/** Timestamp of the most recent trade (fill event) — used in buildAgentState */
|
|
118
130
|
private lastTradeTs;
|
|
119
131
|
/** Epoch ms of the most recent LIVE fill observed this process (issue #248).
|
|
@@ -243,6 +255,20 @@ export declare class GatewayProvider implements OpenClawProvider {
|
|
|
243
255
|
* the WS store; under weight pressure isStoreTrusted() flickers between
|
|
244
256
|
* the two, so the raw symbol alternates poll-to-poll. */
|
|
245
257
|
private canonicalSymbol;
|
|
258
|
+
/** Canonical symbol → the intel DB symbol for this box's venue.
|
|
259
|
+
* binance: 'BTC/USDT' → 'BTCUSDT'; hyperliquid: 'BTC/USDC' → 'HL_BTC'.
|
|
260
|
+
*
|
|
261
|
+
* ★ The old venue-blind `.replace('/','')` produced 'BTCUSDC' on a
|
|
262
|
+
* hyperliquid box — a symbol no intel row has ever carried (intel
|
|
263
|
+
* namespaces every HL row under the 'HL_' prefix). That silently broke the
|
|
264
|
+
* regime/signal/mission/analytics pollers AND wrote every trade result to a
|
|
265
|
+
* symbol Kelly sizing would never read back, so a hyperliquid box stayed at
|
|
266
|
+
* `insufficient_history` even once the reporting path itself worked.
|
|
267
|
+
*
|
|
268
|
+
* FAILS OPEN: an unmappable symbol falls back to the legacy concatenation
|
|
269
|
+
* so intel answers "no data for <echo>" — honest and debuggable — rather
|
|
270
|
+
* than the caller throwing and dropping the report entirely. */
|
|
271
|
+
private toIntelSymbol;
|
|
246
272
|
private onPollerPositions;
|
|
247
273
|
/**
|
|
248
274
|
* Compare previous and current positions from polling.
|
|
@@ -279,6 +305,14 @@ export declare class GatewayProvider implements OpenClawProvider {
|
|
|
279
305
|
private lastMarketStructure;
|
|
280
306
|
private fetchFreshPositions;
|
|
281
307
|
private fetchFreshBalance;
|
|
308
|
+
/** Strict fresh read — null on ANY failure (missing tool, thrown fetch,
|
|
309
|
+
* non-array garbage). Callers whose verdict claims order-state coverage
|
|
310
|
+
* (Kill) MUST distinguish "confirmed list" from "unknown"; collapsing a
|
|
311
|
+
* failed read into the ≤60s cache here was how a stale-empty cache became
|
|
312
|
+
* a green "No open orders to cancel" (audit 2026-07-26 F6). */
|
|
313
|
+
private fetchFreshOpenOrdersOrNull;
|
|
314
|
+
/** Cached-fallback wrapper for DISPLAY surfaces (the reconcile snapshot):
|
|
315
|
+
* stale data beats a blank panel there. Never use for emergency verdicts. */
|
|
282
316
|
private fetchFreshOpenOrders;
|
|
283
317
|
private fetchAgentHealth;
|
|
284
318
|
/**
|
|
@@ -387,13 +421,29 @@ export declare class GatewayProvider implements OpenClawProvider {
|
|
|
387
421
|
* truthful-unknown beats confidently-wrong.
|
|
388
422
|
*/
|
|
389
423
|
private seedLastTradeTs;
|
|
390
|
-
/**
|
|
424
|
+
/** The configured symbol as the intel DB spells it. */
|
|
391
425
|
private get intelligenceSymbol();
|
|
392
426
|
/** Shared intelligence poller with circuit-breaker (exponential backoff on errors). */
|
|
393
427
|
private startIntelligencePoller;
|
|
394
428
|
/** Log first error per poller, suppress subsequent repeats. */
|
|
395
429
|
private trackIntelError;
|
|
396
430
|
private startRegimePoller;
|
|
431
|
+
/**
|
|
432
|
+
* Mirror the tenant's trading params into the risk limits the dashboard
|
|
433
|
+
* banner and the RED-zone auto-flatten run on.
|
|
434
|
+
*
|
|
435
|
+
* Deliberately NOT routed through `startIntelligencePoller`: that helper
|
|
436
|
+
* appends `/${symbol}` and is gated by `RC_SKILL_INTEL_POLL=off`. These
|
|
437
|
+
* limits are a safety input, not a dashboard read — shedding intel display
|
|
438
|
+
* load must not silently revert the operator's configured limits to the
|
|
439
|
+
* fallback ladder.
|
|
440
|
+
*
|
|
441
|
+
* Fail-open by design: a failed fetch keeps the last good values (or the
|
|
442
|
+
* fallback constants on a cold start). We never tighten limits from a
|
|
443
|
+
* parse failure, and we never widen them past what the plugin enforces —
|
|
444
|
+
* the plugin re-reads the same row for the gate that actually blocks.
|
|
445
|
+
*/
|
|
446
|
+
private startTradingParamsPoller;
|
|
397
447
|
private startSignalPoller;
|
|
398
448
|
private startMissionPoller;
|
|
399
449
|
private startAnalyticsPoller;
|