@reefclaw/openclaw-plugin 0.1.13 → 0.1.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/bridge/bridge.d.ts +20 -5
  2. package/bridge/bridge.js +29 -14
  3. package/bridge/config.js +6 -0
  4. package/bridge/gateway/gateway-config.d.ts +16 -5
  5. package/bridge/gateway/gateway-config.js +68 -12
  6. package/bridge/gateway/gateway-ws-client.d.ts +4 -1
  7. package/bridge/gateway/gateway-ws-client.js +41 -11
  8. package/bridge/gateway/poller.js +18 -8
  9. package/bridge/providers/emergency-commands.d.ts +9 -1
  10. package/bridge/providers/emergency-commands.js +38 -1
  11. package/bridge/providers/gateway.d.ts +51 -1
  12. package/bridge/providers/gateway.js +209 -22
  13. package/bridge/providers/onboarding-commands.d.ts +11 -0
  14. package/bridge/providers/onboarding-commands.js +5 -5
  15. package/bridge/providers/risk-calculator.d.ts +61 -2
  16. package/bridge/providers/risk-calculator.js +92 -20
  17. package/bridge/utils/skill-signing.js +8 -3
  18. package/ccxt/binance-public.d.ts +17 -5
  19. package/ccxt/binance-public.js +31 -3
  20. package/config/operator-provenance.d.ts +6 -0
  21. package/config/operator-provenance.js +50 -0
  22. package/config/plugin-config-io.d.ts +15 -1
  23. package/config/plugin-config-io.js +29 -0
  24. package/exchange-adapter.d.ts +13 -0
  25. package/index.js +230 -176
  26. package/ingest/event-loop-monitor.d.ts +22 -0
  27. package/ingest/event-loop-monitor.js +190 -0
  28. package/ingest/position-auto-capture.d.ts +5 -0
  29. package/ingest/position-auto-capture.js +14 -5
  30. package/ingest/readiness-reporter.d.ts +26 -6
  31. package/ingest/readiness-reporter.js +137 -9
  32. package/ingest/skill-version-reader.d.ts +16 -0
  33. package/ingest/skill-version-reader.js +64 -0
  34. package/live/approval-lifecycle.d.ts +30 -0
  35. package/live/approval-lifecycle.js +80 -0
  36. package/live/bracket-types.d.ts +9 -0
  37. package/live/live-adapter.d.ts +0 -1
  38. package/live/user-data-stream.js +10 -2
  39. package/onboarding/runtime.d.ts +34 -1
  40. package/onboarding/runtime.js +56 -5
  41. package/openclaw.plugin.json +1 -1
  42. package/package.json +6 -5
  43. package/risk/pre-trade-check.js +18 -5
  44. package/simulator/exchange-simulator.d.ts +45 -2
  45. package/simulator/exchange-simulator.js +96 -4
  46. package/simulator/types.d.ts +17 -0
  47. package/skills/reefclaw/SKILL.md +6 -11
  48. package/strategy/condition-registry.js +9 -2
  49. package/strategy/evaluator.d.ts +5 -0
  50. package/tools/attach-brackets.js +50 -1
  51. package/tools/cancel-all-orders.js +9 -1
  52. package/tools/create-order.js +18 -1
  53. package/tools/get-bracket-config.d.ts +21 -2
  54. package/tools/get-bracket-config.js +18 -2
  55. package/tools/set-trading-mode.js +6 -3
  56. package/venues/hyperliquid/hl-bracket-coordinator.d.ts +25 -1
  57. package/venues/hyperliquid/hl-bracket-coordinator.js +57 -0
  58. package/venues/hyperliquid/hl-brackets.d.ts +10 -0
  59. package/venues/hyperliquid/hl-brackets.js +45 -13
  60. package/venues/hyperliquid/hl-fill-ingest.d.ts +18 -0
  61. package/venues/hyperliquid/hl-fill-ingest.js +88 -0
  62. package/venues/hyperliquid/hl-live-adapter.d.ts +36 -0
  63. package/venues/hyperliquid/hl-live-adapter.js +116 -7
  64. package/venues/hyperliquid/hl-public.d.ts +12 -5
  65. package/venues/hyperliquid/hl-public.js +24 -3
  66. package/venues/hyperliquid/hl-user-stream.d.ts +13 -1
  67. package/venues/hyperliquid/hl-user-stream.js +4 -1
  68. package/venues/registry.js +8 -7
  69. package/wave9/paper-admission-guard.d.ts +12 -1
  70. package/wave9/paper-admission-guard.js +12 -1
  71. package/scripts/assemble.mjs +0 -130
@@ -66,11 +66,26 @@ export declare class Bridge {
66
66
  private stopThrottleTimer;
67
67
  private handleRequest;
68
68
  private handleStateChange;
69
- /** Today every authenticated session carries operator.write via the
70
- * relay-level Clerk token. This method exists as the enforcement seam —
71
- * a future PR can drop per-session roles in here without restructuring
72
- * the dispatcher. Returning true keeps current behavior identical. */
73
- private hasOperatorWriteScope;
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 scope gate ----
330
- // Methods in OPERATOR_WRITE_METHODS require an elevated scope. Today,
331
- // all connected sessions inherit operator.write from the relay-level
332
- // authentication (Clerk-issued token), so this check is effectively a
333
- // whitelist it exists so a future PR can attach per-session roles
334
- // without restructuring the dispatcher. Emergency methods are on the
335
- // list for the same reason (they are already covered today, but the
336
- // list is the forward-compatible gate).
337
- if (OPERATOR_WRITE_METHODS.has(method) && !this.hasOperatorWriteScope()) {
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
- /** Today every authenticated session carries operator.write via the
582
- * relay-level Clerk token. This method exists as the enforcement seam —
583
- * a future PR can drop per-session roles in here without restructuring
584
- * the dispatcher. Returning true keeps current behavior identical. */
585
- hasOperatorWriteScope() {
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 (optional regime classification) */
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
- * 3. OpenClaw config file (~/.openclaw/openclaw.json)
25
- * 4. Defaults (localhost:18789, BTC/USDT)
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 | null;
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
- const DEFAULT_SYMBOL = 'BTC/USDT';
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
- * 3. OpenClaw config file (~/.openclaw/openclaw.json)
37
- * 4. Defaults (localhost:18789, BTC/USDT)
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
- || DEFAULT_SYMBOL;
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
- || null;
72
- if (intelligenceUrl) {
73
- logger.info(TAG, `Intelligence service: ${intelligenceUrl}`);
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
- constructor(config: Pick<GatewayConfig, 'gatewayUrl' | 'gatewayToken'>, reconnect?: Partial<ReconnectConfig>);
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
- // Convert to WebSocket URL: http→ws, https→wss, bare host:port→ws://
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 = `ws://${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: cron management (the boot-time heartbeat-cron ensure
284
- // calls cron.list/cron.add) moved behind the admin scope on OpenClaw
285
- // 2026.7.x observed live on the first real npx onboarding
286
- // ("Heartbeat cron: skipped (missing scope: operator.admin)",
287
- // 2026-07-22, openclaw 2026.7.1-2). The gateway is localhost-bound and
288
- // this connection already holds operator.write (orders, chat), so the
289
- // marginal grant is small; without it fresh 2026.7.x installs never
290
- // get a heartbeat cron.
291
- scopes: ['operator.read', 'operator.write', 'operator.admin'],
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 }),
@@ -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
- const positions = Array.isArray(result.data) ? result.data : [];
360
- this.callbacks.onPositions(positions);
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
- if (Array.isArray(result.data)) {
375
- for (const ccxt of result.data) {
376
- const mapped = mapCcxtOrder(ccxt);
377
- if (mapped)
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, openOrders: OrderData[]): Promise<EmergencyResult>;
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, openOrders) {
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
- /** Symbol formatted for intelligence API (BTC/USDT BTCUSDT). */
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;