@reefclaw/openclaw-plugin 0.1.28 → 0.1.30

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.
@@ -0,0 +1,30 @@
1
+ /** OpenClaw's default agent id, the one the installer sets the trading agent
2
+ * up as, and the id the bridge has always addressed chat to. */
3
+ export declare const DEFAULT_AGENT_ID = "main";
4
+ /** Trim + lowercase (OpenClaw normalizes ids the same way). Undefined for
5
+ * anything that is not a non-empty string. */
6
+ export declare function normalizeAgentId(value: unknown): string | undefined;
7
+ /** `agent:<agentId>:<rest>` → `<agentId>`. Undefined when the key carries no
8
+ * agent scope (legacy keys, `global`, `unknown`, missing). */
9
+ export declare function resolveAgentIdFromSessionKey(sessionKey: unknown): string | undefined;
10
+ /**
11
+ * OpenClaw's own rule for the gateway's default agent (agent-scope-config.ts
12
+ * `resolveDefaultAgentId`): the `agents.list` entry flagged `default: true`,
13
+ * else the first entry, else `main`. The installer places the ReefClaw skill
14
+ * in that agent's workspace and the heartbeat cron runs on it, so with no
15
+ * override the default agent IS the trading agent. Nobody has to configure
16
+ * anything; an override exists for hand-built multi-agent setups.
17
+ */
18
+ export declare function resolveDefaultAgentIdFromConfig(cfg: unknown): string;
19
+ /** The agent an event belongs to, when the payload says. An explicit
20
+ * `agentId` field wins (future-proofing; today's gateway sends only the
21
+ * session key). */
22
+ export declare function resolveAgentEventOwner(payload: {
23
+ agentId?: unknown;
24
+ sessionKey?: unknown;
25
+ }): string | undefined;
26
+ /** True when the event is attributable to an agent OTHER than ours. */
27
+ export declare function isForeignAgentEvent(payload: {
28
+ agentId?: unknown;
29
+ sessionKey?: unknown;
30
+ }, ownAgentId: string): boolean;
@@ -0,0 +1,67 @@
1
+ // Which OpenClaw agent this bridge belongs to, and how to tell whose run an
2
+ // incoming `agent` event is.
3
+ //
4
+ // An OpenClaw gateway can host several agents (`agents.list`), and it
5
+ // broadcasts EVERY agent's run events to EVERY operator connection —
6
+ // `sendAgentPayload` → `broadcast('agent', …)` in OpenClaw's server-chat.ts
7
+ // carries no per-agent scoping, and an operator token cannot be scoped to one
8
+ // agent. Until 2026-09-16 the bridge forwarded whatever arrived, so on a
9
+ // shared gateway a second agent's morning briefing streamed into the ReefClaw
10
+ // dashboard chat and across the relay (reported by a self-hosting subscriber
11
+ // against 0.1.31 and 0.1.40). The only owner marker on the event is its
12
+ // session key: `agent:<agentId>:<rest>` (OpenClaw routing/session-key.ts).
13
+ //
14
+ // Fail-open on purpose: an event with NO attributable session key (older
15
+ // gateways omit it; legacy/unscoped keys such as `global`) is treated as our
16
+ // own. Dropping it would blind the chat on exactly the single-agent boxes
17
+ // that cannot leak anything.
18
+ /** OpenClaw's default agent id, the one the installer sets the trading agent
19
+ * up as, and the id the bridge has always addressed chat to. */
20
+ export const DEFAULT_AGENT_ID = 'main';
21
+ /** Trim + lowercase (OpenClaw normalizes ids the same way). Undefined for
22
+ * anything that is not a non-empty string. */
23
+ export function normalizeAgentId(value) {
24
+ if (typeof value !== 'string')
25
+ return undefined;
26
+ const trimmed = value.trim().toLowerCase();
27
+ return trimmed.length > 0 ? trimmed : undefined;
28
+ }
29
+ /** `agent:<agentId>:<rest>` → `<agentId>`. Undefined when the key carries no
30
+ * agent scope (legacy keys, `global`, `unknown`, missing). */
31
+ export function resolveAgentIdFromSessionKey(sessionKey) {
32
+ if (typeof sessionKey !== 'string')
33
+ return undefined;
34
+ const key = sessionKey.trim();
35
+ if (!/^agent:/i.test(key))
36
+ return undefined;
37
+ return normalizeAgentId(key.split(':')[1]);
38
+ }
39
+ /**
40
+ * OpenClaw's own rule for the gateway's default agent (agent-scope-config.ts
41
+ * `resolveDefaultAgentId`): the `agents.list` entry flagged `default: true`,
42
+ * else the first entry, else `main`. The installer places the ReefClaw skill
43
+ * in that agent's workspace and the heartbeat cron runs on it, so with no
44
+ * override the default agent IS the trading agent. Nobody has to configure
45
+ * anything; an override exists for hand-built multi-agent setups.
46
+ */
47
+ export function resolveDefaultAgentIdFromConfig(cfg) {
48
+ const list = cfg?.agents?.list;
49
+ if (!Array.isArray(list))
50
+ return DEFAULT_AGENT_ID;
51
+ const entries = list.filter((e) => !!e && typeof e === 'object' && !Array.isArray(e));
52
+ if (entries.length === 0)
53
+ return DEFAULT_AGENT_ID;
54
+ const chosen = entries.find((e) => e.default === true) ?? entries[0];
55
+ return normalizeAgentId(chosen.id) ?? DEFAULT_AGENT_ID;
56
+ }
57
+ /** The agent an event belongs to, when the payload says. An explicit
58
+ * `agentId` field wins (future-proofing; today's gateway sends only the
59
+ * session key). */
60
+ export function resolveAgentEventOwner(payload) {
61
+ return normalizeAgentId(payload.agentId) ?? resolveAgentIdFromSessionKey(payload.sessionKey);
62
+ }
63
+ /** True when the event is attributable to an agent OTHER than ours. */
64
+ export function isForeignAgentEvent(payload, ownAgentId) {
65
+ const owner = resolveAgentEventOwner(payload);
66
+ return owner !== undefined && owner !== (normalizeAgentId(ownAgentId) ?? DEFAULT_AGENT_ID);
67
+ }
@@ -16,6 +16,13 @@ export interface GatewayConfig {
16
16
  * Optional so a hand-built config (tests, embedders) stays valid; every
17
17
  * consumer treats absent as 'binance', the historical behaviour. */
18
18
  venue?: VenueId;
19
+ /** The OpenClaw agent this bridge serves: chat is addressed to it and ONLY
20
+ * its run events reach the dashboard (gateway/agent-scope.ts). Optional so
21
+ * a hand-built config stays valid; consumers default to 'main'. */
22
+ agentId?: string;
23
+ /** Per-host switch for the dashboard's one-click connector update, which
24
+ * opens a PTY on this box (providers/connector-update.ts). Absent = allowed. */
25
+ allowConnectorUpdate?: boolean;
19
26
  }
20
27
  /** CLI args specific to gateway provider */
21
28
  export interface GatewayCliArgs {
@@ -23,14 +30,20 @@ export interface GatewayCliArgs {
23
30
  gatewayToken?: string;
24
31
  symbol?: string;
25
32
  intelligenceUrl?: string;
33
+ agentId?: string;
26
34
  }
35
+ /** `1/true/on/yes/enabled` → true, `0/false/off/no/disabled` → false, anything
36
+ * else (unset, blank, garbage) → undefined so the next source is consulted. */
37
+ export declare function parseOnOffSwitch(raw: unknown): boolean | undefined;
27
38
  /**
28
39
  * Resolve gateway connection config with priority:
29
- * 1. CLI args (--gateway-url, --gateway-token, --symbol)
40
+ * 1. CLI args (--gateway-url, --gateway-token, --symbol, --agent-id)
30
41
  * 2. Env vars (OPENCLAW_GATEWAY_URL, OPENCLAW_GATEWAY_TOKEN, REEFCLAW_SYMBOL,
31
- * REEFCLAW_INTELLIGENCE_URL, REEFCLAW_VENUE)
42
+ * REEFCLAW_INTELLIGENCE_URL, REEFCLAW_VENUE, OPENCLAW_AGENT_ID,
43
+ * REEFCLAW_ALLOW_CONNECTOR_UPDATE)
32
44
  * 3. Config files (~/.openclaw/openclaw.json, ~/.reefclaw/plugin-config.json)
33
- * 4. Defaults (localhost:18789, the venue's default symbol, intel.reefclaw.com)
45
+ * 4. Defaults (localhost:18789, the venue's default symbol, intel.reefclaw.com,
46
+ * the gateway's default agent)
34
47
  *
35
48
  * Returns null for gatewayToken if it can't be resolved (caller must handle).
36
49
  * `intelligenceUrl` is never null — see DEFAULT_INTELLIGENCE_URL for why that
@@ -42,6 +55,8 @@ export declare function resolveGatewayConfig(args: GatewayCliArgs): {
42
55
  symbol: string;
43
56
  intelligenceUrl: string;
44
57
  venue: VenueId;
58
+ agentId: string;
59
+ allowConnectorUpdate: boolean;
45
60
  };
46
61
  /**
47
62
  * Validate that the resolved config has all required fields.
@@ -7,6 +7,7 @@ import { homedir } from 'os';
7
7
  import JSON5 from 'json5';
8
8
  import { parseVenue, VENUE_DEFAULT_SYMBOL } from '@reefclaw/shared';
9
9
  import { logger } from '../logger.js';
10
+ import { normalizeAgentId, resolveDefaultAgentIdFromConfig } from './agent-scope.js';
10
11
  const TAG = 'gateway-config';
11
12
  const DEFAULT_GATEWAY_PORT = 18789;
12
13
  const DEFAULT_GATEWAY_HOST = 'localhost';
@@ -70,14 +71,30 @@ function readPluginConfig() {
70
71
  return null;
71
72
  }
72
73
  }
74
+ /** `1/true/on/yes/enabled` → true, `0/false/off/no/disabled` → false, anything
75
+ * else (unset, blank, garbage) → undefined so the next source is consulted. */
76
+ export function parseOnOffSwitch(raw) {
77
+ if (typeof raw === 'boolean')
78
+ return raw;
79
+ if (typeof raw !== 'string')
80
+ return undefined;
81
+ const v = raw.trim().toLowerCase();
82
+ if (['1', 'true', 'on', 'yes', 'enabled'].includes(v))
83
+ return true;
84
+ if (['0', 'false', 'off', 'no', 'disabled'].includes(v))
85
+ return false;
86
+ return undefined;
87
+ }
73
88
  // ---- Resolve gateway config ----
74
89
  /**
75
90
  * Resolve gateway connection config with priority:
76
- * 1. CLI args (--gateway-url, --gateway-token, --symbol)
91
+ * 1. CLI args (--gateway-url, --gateway-token, --symbol, --agent-id)
77
92
  * 2. Env vars (OPENCLAW_GATEWAY_URL, OPENCLAW_GATEWAY_TOKEN, REEFCLAW_SYMBOL,
78
- * REEFCLAW_INTELLIGENCE_URL, REEFCLAW_VENUE)
93
+ * REEFCLAW_INTELLIGENCE_URL, REEFCLAW_VENUE, OPENCLAW_AGENT_ID,
94
+ * REEFCLAW_ALLOW_CONNECTOR_UPDATE)
79
95
  * 3. Config files (~/.openclaw/openclaw.json, ~/.reefclaw/plugin-config.json)
80
- * 4. Defaults (localhost:18789, the venue's default symbol, intel.reefclaw.com)
96
+ * 4. Defaults (localhost:18789, the venue's default symbol, intel.reefclaw.com,
97
+ * the gateway's default agent)
81
98
  *
82
99
  * Returns null for gatewayToken if it can't be resolved (caller must handle).
83
100
  * `intelligenceUrl` is never null — see DEFAULT_INTELLIGENCE_URL for why that
@@ -127,9 +144,30 @@ export function resolveGatewayConfig(args) {
127
144
  || process.env.REEFCLAW_INTELLIGENCE_URL
128
145
  || pluginConfig?.intelligenceUrl
129
146
  || DEFAULT_INTELLIGENCE_URL;
147
+ // --- Agent id ---
148
+ // CLI > env > plugin-config.json agentId > the gateway's DEFAULT agent from
149
+ // openclaw.json (OpenClaw's own rule, see resolveDefaultAgentIdFromConfig)
150
+ // > 'main'. Zero configuration for every installer-built box: the installer
151
+ // puts the skill in the default agent's workspace and the heartbeat cron
152
+ // runs on it. ONE value drives both chat addressing and the agent-event
153
+ // filter (agent-scope.ts), so a multi-agent gateway can never end up
154
+ // chatting to one agent while displaying another's runs.
155
+ const overrideAgentId = normalizeAgentId(args.agentId)
156
+ ?? normalizeAgentId(process.env.OPENCLAW_AGENT_ID)
157
+ ?? normalizeAgentId(pluginConfig?.agentId);
158
+ const agentId = overrideAgentId ?? resolveDefaultAgentIdFromConfig(ocConfig);
159
+ const agentIdSource = overrideAgentId ? 'configured override' : 'gateway default agent';
160
+ // --- One-click connector update switch ---
161
+ // env > plugin-config.json connectorUpdate > allowed. A per-host opt-out
162
+ // for the dashboard's "Update connector" (which opens a PTY on this box);
163
+ // see providers/connector-update.ts.
164
+ const allowConnectorUpdate = parseOnOffSwitch(process.env.REEFCLAW_ALLOW_CONNECTOR_UPDATE)
165
+ ?? parseOnOffSwitch(pluginConfig?.connectorUpdate)
166
+ ?? true;
130
167
  logger.info(TAG, `Intelligence service: ${intelligenceUrl} (venue=${venue})`);
168
+ logger.info(TAG, `Agent: ${agentId} (${agentIdSource}); one-click connector update: ${allowConnectorUpdate ? 'allowed' : 'DISABLED on this box'}`);
131
169
  logger.debug(TAG, `Resolved: url=${gatewayUrl}, token=${gatewayToken ? '***' : '(none)'}, symbol=${symbol}`);
132
- return { gatewayUrl, gatewayToken, symbol, intelligenceUrl, venue };
170
+ return { gatewayUrl, gatewayToken, symbol, intelligenceUrl, venue, agentId, allowConnectorUpdate };
133
171
  }
134
172
  /**
135
173
  * Validate that the resolved config has all required fields.
@@ -51,6 +51,7 @@ export type GatewayWsEventPayload = {
51
51
  seq?: number;
52
52
  sessionKey?: string;
53
53
  sessionId?: string;
54
+ agentId?: string;
54
55
  };
55
56
  presence: {
56
57
  payload: unknown;
@@ -344,6 +344,9 @@ export class GatewayWsClient {
344
344
  seq: frame.seq,
345
345
  ...(typeof p.sessionKey === 'string' ? { sessionKey: p.sessionKey } : {}),
346
346
  ...(typeof p.sessionId === 'string' ? { sessionId: p.sessionId } : {}),
347
+ // Not sent by today's gateway (the owner rides in sessionKey);
348
+ // forwarded if a future build adds it — see agent-scope.ts.
349
+ ...(typeof p.agentId === 'string' ? { agentId: p.agentId } : {}),
347
350
  });
348
351
  }
349
352
  break;
package/bridge/index.js CHANGED
@@ -91,6 +91,10 @@ function parseArgs() {
91
91
  gateway.symbol = next;
92
92
  i++;
93
93
  break;
94
+ case '--agent-id':
95
+ gateway.agentId = next;
96
+ i++;
97
+ break;
94
98
  case '--log-level':
95
99
  case '-l':
96
100
  if (['debug', 'info', 'warn', 'error'].includes(next)) {
@@ -118,36 +122,37 @@ function parseArgs() {
118
122
  return { cli, gateway };
119
123
  }
120
124
  function printUsage() {
121
- console.log(`
122
- ReefClaw Skill — Bridge between OpenClaw and ReefClaw relay
123
-
124
- Usage:
125
- tsx src/index.ts [options]
126
-
127
- Options:
128
- --provider, -p <mock|gateway> Provider type (default: mock)
129
- --token, -t <token> Connection token (or REEFCLAW_TOKEN env)
130
- --user-id, -u <id> User ID (or REEFCLAW_USER_ID env)
131
- --relay-url, -r <url> Relay URL (default: wss://reefclaw.radunlupsa.partykit.dev)
132
- --gateway-url <url> OpenClaw gateway URL (or OPENCLAW_GATEWAY_URL env)
133
- --gateway-token <token> OpenClaw gateway token (or OPENCLAW_GATEWAY_TOKEN env)
134
- --symbol <symbol> Trading symbol (default: BTC/USDT, or REEFCLAW_SYMBOL env)
135
- --log-level, -l <level> Log level: debug, info, warn, error (default: info)
136
- --setup, -s [token] Run setup flow (validate token, test connection, save to config)
137
- --help, -h Show this help
138
-
139
- Relay config resolution (highest priority first):
140
- 1. CLI arguments (--token, --user-id, --relay-url)
141
- 2. Environment variables (REEFCLAW_TOKEN, REEFCLAW_USER_ID, REEFCLAW_RELAY_URL)
142
- 3. OpenClaw config file (~/.openclaw/openclaw.json skills.entries.reefclaw)
143
- 4. Fallback env vars (MOCK_TOKEN, USER_ID)
144
- 5. Defaults (relay URL wss://reefclaw.radunlupsa.partykit.dev)
145
-
146
- Gateway config resolution (for --provider gateway):
147
- 1. CLI arguments (--gateway-url, --gateway-token, --symbol)
148
- 2. Environment variables (OPENCLAW_GATEWAY_URL, OPENCLAW_GATEWAY_TOKEN, REEFCLAW_SYMBOL)
149
- 3. OpenClaw config file (~/.openclaw/openclaw.json gateway.auth.token, gateway.port)
150
- 4. Defaults (http://localhost:18789, BTC/USDT)
125
+ console.log(`
126
+ ReefClaw Skill — Bridge between OpenClaw and ReefClaw relay
127
+
128
+ Usage:
129
+ tsx src/index.ts [options]
130
+
131
+ Options:
132
+ --provider, -p <mock|gateway> Provider type (default: mock)
133
+ --token, -t <token> Connection token (or REEFCLAW_TOKEN env)
134
+ --user-id, -u <id> User ID (or REEFCLAW_USER_ID env)
135
+ --relay-url, -r <url> Relay URL (default: wss://reefclaw.radunlupsa.partykit.dev)
136
+ --gateway-url <url> OpenClaw gateway URL (or OPENCLAW_GATEWAY_URL env)
137
+ --gateway-token <token> OpenClaw gateway token (or OPENCLAW_GATEWAY_TOKEN env)
138
+ --symbol <symbol> Trading symbol (default: BTC/USDT, or REEFCLAW_SYMBOL env)
139
+ --agent-id <id> OpenClaw agent this bridge serves (default: main, or OPENCLAW_AGENT_ID env)
140
+ --log-level, -l <level> Log level: debug, info, warn, error (default: info)
141
+ --setup, -s [token] Run setup flow (validate token, test connection, save to config)
142
+ --help, -h Show this help
143
+
144
+ Relay config resolution (highest priority first):
145
+ 1. CLI arguments (--token, --user-id, --relay-url)
146
+ 2. Environment variables (REEFCLAW_TOKEN, REEFCLAW_USER_ID, REEFCLAW_RELAY_URL)
147
+ 3. OpenClaw config file (~/.openclaw/openclaw.json → skills.entries.reefclaw)
148
+ 4. Fallback env vars (MOCK_TOKEN, USER_ID)
149
+ 5. Defaults (relay URL → wss://reefclaw.radunlupsa.partykit.dev)
150
+
151
+ Gateway config resolution (for --provider gateway):
152
+ 1. CLI arguments (--gateway-url, --gateway-token, --symbol, --agent-id)
153
+ 2. Environment variables (OPENCLAW_GATEWAY_URL, OPENCLAW_GATEWAY_TOKEN, REEFCLAW_SYMBOL, OPENCLAW_AGENT_ID)
154
+ 3. OpenClaw config file (~/.openclaw/openclaw.json → gateway.auth.token, gateway.port)
155
+ 4. Defaults (http://localhost:18789, BTC/USDT)
151
156
  `);
152
157
  }
153
158
  // ---- Main ----
@@ -45,6 +45,9 @@ export interface ConnectorUpdateOutcome {
45
45
  * - agent runs in a sandbox → in-sandbox terminals are unsupported
46
46
  */
47
47
  export declare function describeOpenFailure(err: unknown): ConnectorUpdateOutcome;
48
+ /** The operator switched one-click updates off on this box. Refused before
49
+ * any gateway call is made; the copy carries the manual path. */
50
+ export declare function describeUpdateDisabled(): ConnectorUpdateOutcome;
48
51
  /**
49
52
  * Once the command is running, losing the transport is EXPECTED — the installer
50
53
  * restarts the gateway, which is the process hosting both the PTY and our own
@@ -33,6 +33,22 @@
33
33
  // `classifyTransportLoss`.
34
34
  // - The PTY runs as the gateway's own user (`openclaw`), which is exactly the
35
35
  // user the installer must run as. Correct by construction.
36
+ //
37
+ // Hardening after the 2026-09-16 self-hoster audit (a relay message that ends
38
+ // in a PTY on the operator's machine is a capability worth fencing even with
39
+ // a constant command):
40
+ // - Per-host opt-out: REEFCLAW_ALLOW_CONNECTOR_UPDATE=0 (or plugin-config.json
41
+ // `"connectorUpdate": "off"`) makes the provider refuse before any gateway
42
+ // call (`describeUpdateDisabled`). The gateway's own switch
43
+ // `gateway.terminal.enabled=false` is honoured too (`describeOpenFailure`).
44
+ // - terminal.* needs operator.admin: the update opens a separate short-lived
45
+ // admin session and checks the granted scopes before `terminal.open`.
46
+ // - Residual trust: `@latest` resolves on the npm registry, so the trust root
47
+ // is the registry plus the publisher account (2FA), exactly as for a manual
48
+ // `npx`. Pinning an exact, offline-signed version is the planned follow-up
49
+ // (the SKILL.md OTA already carries the Ed25519 key for it).
50
+ // - The updater overwrites local modifications to the placed bridge/plugin,
51
+ // as any reinstall would. Self-hosters carrying patches: switch it off.
36
52
  import { logger } from '../logger.js';
37
53
  const TAG = 'connector-update';
38
54
  /**
@@ -105,6 +121,16 @@ export function describeOpenFailure(err) {
105
121
  message: `Could not open a terminal on the box: ${raw}`,
106
122
  };
107
123
  }
124
+ /** The operator switched one-click updates off on this box. Refused before
125
+ * any gateway call is made; the copy carries the manual path. */
126
+ export function describeUpdateDisabled() {
127
+ return {
128
+ ok: false,
129
+ status: 'blocked',
130
+ message: 'One-click updates are switched off on this box (REEFCLAW_ALLOW_CONNECTOR_UPDATE=0). '
131
+ + `Update from a shell on the box instead: ${CONNECTOR_UPDATE_COMMAND}`,
132
+ };
133
+ }
108
134
  /**
109
135
  * Once the command is running, losing the transport is EXPECTED — the installer
110
136
  * restarts the gateway, which is the process hosting both the PTY and our own
@@ -40,6 +40,11 @@ export declare class GatewayProvider implements OpenClawProvider {
40
40
  * Surfaced in agent_state so the dashboard header can show the real agent +
41
41
  * model instead of a hardcoded placeholder. Empty until the gateway answers. */
42
42
  private agentIdentity;
43
+ /** Foreign agent ids whose runs we have already logged as ignored (once each). */
44
+ private readonly ignoredForeignAgents;
45
+ /** The OpenClaw agent this bridge serves: chat goes to it, and only its runs
46
+ * reach the dashboard (gateway/agent-scope.ts). */
47
+ private agentId;
43
48
  /** Emit the one-time rollout probe (gateway response shape) only on the first attempt. */
44
49
  private loggedAgentIdentityProbe;
45
50
  /** Last IDENTITY.md read (epoch ms) — TTL gate for the display-name re-read. */
@@ -19,7 +19,8 @@ import { parseIdentityName } from '../utils/identity-name.js';
19
19
  import { computeEquity as _computeEquity, computePositionNotional as _computePositionNotional, computeRiskMetrics as _computeRiskMetrics, DEFAULT_RISK_LIMITS, DRAWDOWN_ZONE_THRESHOLDS, TENANT_LIMIT_BOUNDS, boundedNum, validateDrawdownLadder, } from './risk-calculator.js';
20
20
  import { executeKill as _executeKill, executeFlatten as _executeFlatten, executePause as _executePause, executeResume as _executeResume, cancelPendingProposals, withProposalDetail, } from './emergency-commands.js';
21
21
  import { executeSetTradingMode, executeGetBracketConfig, executeSetBracketRequirement, executeSetExchangeCredentials, executeTestExchangeCredentials, executeClearExchangeCredentials, executeProvisionHlAgentWallet, executeHlAgentWalletStatus, executeSubmitHlAgentApproval, } from './onboarding-commands.js';
22
- import { startConnectorUpdate, runConnectorUpdate, openAdminGatewaySession, checkPositionGuard, } from './connector-update.js';
22
+ import { startConnectorUpdate, runConnectorUpdate, openAdminGatewaySession, checkPositionGuard, describeUpdateDisabled, } from './connector-update.js';
23
+ import { DEFAULT_AGENT_ID, isForeignAgentEvent, resolveAgentEventOwner } from '../gateway/agent-scope.js';
23
24
  const TAG = 'gateway';
24
25
  // ---- Day-start NAV persistence ----
25
26
  // Persists sessionStartNav (the UTC-day P&L anchor) per date so Day P&L
@@ -159,6 +160,13 @@ export class GatewayProvider {
159
160
  * Surfaced in agent_state so the dashboard header can show the real agent +
160
161
  * model instead of a hardcoded placeholder. Empty until the gateway answers. */
161
162
  agentIdentity = {};
163
+ /** Foreign agent ids whose runs we have already logged as ignored (once each). */
164
+ ignoredForeignAgents = new Set();
165
+ /** The OpenClaw agent this bridge serves: chat goes to it, and only its runs
166
+ * reach the dashboard (gateway/agent-scope.ts). */
167
+ agentId() {
168
+ return this.config.agentId ?? DEFAULT_AGENT_ID;
169
+ }
162
170
  /** Emit the one-time rollout probe (gateway response shape) only on the first attempt. */
163
171
  loggedAgentIdentityProbe = false;
164
172
  /** Last IDENTITY.md read (epoch ms) — TTL gate for the display-name re-read. */
@@ -669,7 +677,7 @@ export class GatewayProvider {
669
677
  }
670
678
  try {
671
679
  const idempotencyKey = `chat-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
672
- await ws.sendRpc('agent', { message: content, idempotencyKey, agentId: 'main' });
680
+ await ws.sendRpc('agent', { message: content, idempotencyKey, agentId: this.agentId() });
673
681
  return { received: true };
674
682
  }
675
683
  catch (err) {
@@ -749,6 +757,13 @@ export class GatewayProvider {
749
757
  * `restarting` and the dashboard waits for the reconnect.
750
758
  */
751
759
  async updateConnector(args) {
760
+ // Per-host opt-out FIRST: a box whose operator switched this off must
761
+ // never open a PTY, connected or not (REEFCLAW_ALLOW_CONNECTOR_UPDATE=0 /
762
+ // plugin-config.json connectorUpdate:'off').
763
+ if (this.config.allowConnectorUpdate === false) {
764
+ logger.info(TAG, 'connector.update refused: one-click updates are switched off on this box');
765
+ return describeUpdateDisabled();
766
+ }
752
767
  if (!this.wsClient) {
753
768
  return { ok: false, status: 'failed', message: 'Not connected to the OpenClaw gateway.' };
754
769
  }
@@ -1366,6 +1381,19 @@ export class GatewayProvider {
1366
1381
  onAgentEvent(payload) {
1367
1382
  if (!this.started || !this.eventParser)
1368
1383
  return;
1384
+ // Only OUR agent's runs belong in the dashboard. The gateway broadcasts
1385
+ // every agent's events to every operator connection, so on a shared
1386
+ // gateway a neighbour's cron briefing used to stream into the chat and
1387
+ // across the relay (2026-09-16 audit, item 1). See gateway/agent-scope.ts.
1388
+ if (isForeignAgentEvent(payload, this.agentId())) {
1389
+ const owner = resolveAgentEventOwner(payload) ?? '?';
1390
+ if (!this.ignoredForeignAgents.has(owner)) {
1391
+ this.ignoredForeignAgents.add(owner);
1392
+ logger.warn(TAG, `Ignoring run events from agent '${owner}' — this bridge serves '${this.agentId()}' ` +
1393
+ '(the gateway default agent unless overridden; set OPENCLAW_AGENT_ID / --agent-id if that is the wrong agent)');
1394
+ }
1395
+ return;
1396
+ }
1369
1397
  // Turn lifecycle WITH the session key — the heartbeat flight recorder
1370
1398
  // keys off `agent:main:cron:…` to scan the beat's transcript right after
1371
1399
  // it ends. Fired before parsing so it never depends on parser state.
@@ -2345,7 +2373,8 @@ export class GatewayProvider {
2345
2373
  (Array.isArray(root.data) && root.data) ||
2346
2374
  [root];
2347
2375
  const rows = rowsRaw.filter((r) => !!r && typeof r === 'object');
2348
- const row = rows.find((r) => r.agentId === 'main') ?? rows[0];
2376
+ const own = this.agentId();
2377
+ const row = rows.find((r) => r.agentId === own) ?? rows[0];
2349
2378
  const runtime = row ? (asObj(row.agentRuntime) ?? asObj(row.runtime)) : undefined;
2350
2379
  const defaults = asObj(root.defaults);
2351
2380
  return flatModel(row?.model) ?? flatModel(runtime?.model) ?? flatModel(defaults?.model) ?? flatModel(root.model);
@@ -3104,7 +3133,7 @@ export class GatewayProvider {
3104
3133
  ].join('\n');
3105
3134
  try {
3106
3135
  const idempotencyKey = `mission-${mission.missionId}-${Date.now()}`;
3107
- await ws.sendRpc('agent', { message, idempotencyKey, agentId: 'main' });
3136
+ await ws.sendRpc('agent', { message, idempotencyKey, agentId: this.agentId() });
3108
3137
  logger.info(TAG, `Presented mission ${mission.missionId} to agent ($${absoluteSize.toFixed(2)}, ${quantity.toFixed(6)} units)`);
3109
3138
  }
3110
3139
  catch (err) {
@@ -48,8 +48,13 @@ export interface IExchangeAdapter {
48
48
  * @param closeReason Tags the resulting Trade record with who initiated the close.
49
49
  * Paper mode persists it on the closing Trade.metadata.closeReason so the agent
50
50
  * can see on its next heartbeat that e.g. a stop_watcher auto-closed the trade.
51
- * Live mode currently ignores this (metadata not tracked in live path). */
52
- closePosition(symbol: string, closeReason?: CloseReason): Promise<CcxtOrder>;
51
+ * Live mode currently ignores this (metadata not tracked in live path).
52
+ * @param referencePrice Paper-only: the price the closing DECISION was made
53
+ * on (a stop's breach mark, a target level). The simulator anchors the
54
+ * fill to it instead of whatever tick/book it has cached: a stop that
55
+ * filled off an entry-time cache booked a -1.04R loss as -0.02R
56
+ * (2026-09-16 audit). Live venues fill at market and ignore it. */
57
+ closePosition(symbol: string, closeReason?: CloseReason, referencePrice?: number): Promise<CcxtOrder>;
53
58
  getBalance(): Promise<CcxtBalance>;
54
59
  getPositions(symbol?: string): Promise<CcxtPosition[]>;
55
60
  /**
@@ -355,6 +355,12 @@ export function startReadinessReporter(opts) {
355
355
  publicApi: opts.publicApi,
356
356
  toolCount: opts.toolCount,
357
357
  resolveStopProtection: opts.resolveStopProtection,
358
+ // The dashboard's update banner reads these off EVERY post (boot and
359
+ // periodic). 0.1.28-0.1.29 received them here and dropped them, so
360
+ // every updated box posted agent:{} without a version and the banner
361
+ // went silent for it (found 2026-09-16 on the rig).
362
+ pluginVersion: opts.pluginVersion,
363
+ installChannel: opts.installChannel,
358
364
  }, bootWarmup, { state });
359
365
  await postReadiness(opts.apiBaseUrl, opts.token, report, fetchImpl, timeoutMs);
360
366
  if (report.overall === 'fail') {
@@ -212,7 +212,13 @@ export class PositionWatcher extends EventEmitter {
212
212
  throw new Error(`Wave 9 ownership resolution failed before stop close submission: ${ownershipError}; ` +
213
213
  'IMMEDIATE MANUAL INTERVENTION REQUIRED');
214
214
  }
215
- closeOrder = await this.adapter.closePosition(trustedPosition.symbol, 'stop_watcher');
215
+ // Fill at the mark the breach decision was made on, not at whatever the
216
+ // paper engine has cached (the take-profit leg already passes its level
217
+ // the same way). Live adapters fill at market and ignore the reference.
218
+ const stopFillPrice = Number.isFinite(trustedPosition.markPrice) && trustedPosition.markPrice > 0
219
+ ? trustedPosition.markPrice
220
+ : undefined;
221
+ closeOrder = await this.adapter.closePosition(trustedPosition.symbol, 'stop_watcher', stopFillPrice);
216
222
  if (!candidateId || !this.wave9CloseLifecycle)
217
223
  return 'closed';
218
224
  const outcome = await this.wave9CloseLifecycle.settleAfterClose(candidateId, trustedPosition.symbol);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "reefclaw-paper-trading",
3
3
  "name": "ReefClaw Trading",
4
- "version": "0.1.28",
4
+ "version": "0.1.30",
5
5
  "description": "Supervised trading plugin for the ReefClaw dashboard. It runs on YOUR machine and starts in PAPER mode with no API keys. It cannot trade real funds until you supply exchange credentials and step PAPER→MICRO_LIVE→LIVE yourself from the dashboard — the agent cannot make that change (the tool is refused without operator provenance). Exchange keys stay local, are used only to sign requests to the exchange, and are never transmitted to ReefClaw (asserted by a test in this package). Trading telemetry — positions, fills, decision journal — is sent to ReefClaw to render the dashboard. Every live position carries exchange-native protective stops. Remote updates to the agent's trading instructions are applied only after an Ed25519 signature is verified against a public key pinned in this build.",
6
6
  "author": "ReefClaw",
7
7
  "activation": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reefclaw/openclaw-plugin",
3
- "version": "0.1.28",
3
+ "version": "0.1.30",
4
4
  "description": "ReefClaw supervised trading plugin for OpenClaw. Runs entirely on YOUR machine and starts in PAPER mode — it cannot trade real funds until you supply exchange credentials and walk the PAPER→MICRO_LIVE→LIVE ladder yourself from the ReefClaw dashboard (the agent cannot make that change; it is refused without operator provenance). Your exchange API keys stay on your machine to sign requests to the exchange and are NEVER sent to ReefClaw — a test in the package asserts this. What does reach ReefClaw is trading telemetry for the dashboard (positions, fills, decision journal). Live trading always carries exchange-native protective stops. Trading instructions can be updated remotely, and every update must carry a valid Ed25519 signature verified against a key pinned in this build before it is applied. Install: npx --yes @reefclaw/connect, or from ClawHub on OpenClaw 2026.8.1+ (Control UI Plugins > Discover, or /plugins install clawhub:@reefclaw/openclaw-plugin then the same with --accept-capabilities after reviewing the listed capabilities)",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -13,7 +13,7 @@ export declare class PaperAdapter implements IExchangeAdapter {
13
13
  createOrder(symbol: string, side: 'buy' | 'sell', type: 'market' | 'limit', amount: number, price?: number, metadata?: PositionMetadata, _options?: OrderOptions): Promise<CcxtOrder>;
14
14
  cancelOrder(orderId: string, _symbol?: string): Promise<CcxtOrder>;
15
15
  cancelAllOrders(symbol?: string): Promise<CcxtOrder[]>;
16
- closePosition(symbol: string, closeReason?: CloseReason): Promise<CcxtOrder>;
16
+ closePosition(symbol: string, closeReason?: CloseReason, referencePrice?: number): Promise<CcxtOrder>;
17
17
  getBalance(): Promise<CcxtBalance>;
18
18
  getPositions(symbol?: string): Promise<CcxtPosition[]>;
19
19
  /** Paper has no exchange fetch to fail — position state is always known. */
package/paper-adapter.js CHANGED
@@ -22,8 +22,8 @@ export class PaperAdapter {
22
22
  async cancelAllOrders(symbol) {
23
23
  return this.simulator.cancelAllOrders(symbol);
24
24
  }
25
- async closePosition(symbol, closeReason) {
26
- return this.simulator.closePosition(symbol, closeReason);
25
+ async closePosition(symbol, closeReason, referencePrice) {
26
+ return this.simulator.closePosition(symbol, closeReason, referencePrice);
27
27
  }
28
28
  // Reads pull a newer state.json first (ExchangeSimulator.refreshState —
29
29
  // no-op without a hook). Out-of-tool readers (DB-vs-exchange sweep,
@@ -1,5 +1,7 @@
1
1
  {
2
2
  "_comment": "One line per released plugin version, shown in the dashboard's update banner. Baked into the webapp at build by webapp/scripts/generate-skill-content.mjs (LATEST_PLUGIN_NOTES = the entry for plugin-package/package.json#version). Add a line in the same PR that bumps the version.",
3
3
  "0.1.27": "Fixes paper positions showing as closed at $0 in the Journal while still open, and adds a 2-minute grace to the position reconciler.",
4
- "0.1.28": "Fixes the connector's gateway handshake on OpenClaw 2026.9+ (it now connects as OpenClaw's local-backend client) and adds in-app update notices with one-click updates."
4
+ "0.1.28": "Fixes the connector's gateway handshake on OpenClaw 2026.9+ (it now connects as OpenClaw's local-backend client) and adds in-app update notices with one-click updates.",
5
+ "0.1.29": "Paper stop and target fills now use the price the decision was made on when the cached order book is stale (paper P&L gets more honest); on a gateway with several agents the connector shows only its own agent's runs; one-click updates can be switched off per box.",
6
+ "0.1.30": "Restores the connector's version reporting, so the dashboard's update notice works again for boxes that already updated (0.1.28 and 0.1.29 reported no version and could not be nudged)."
5
7
  }
@@ -9,6 +9,7 @@ import { randomUUID } from 'node:crypto';
9
9
  import { logger, formatError } from '../logger.js';
10
10
  import { MAX_TRADE_HISTORY, DEFAULT_SIMULATION_CONFIG } from './types.js';
11
11
  import { fillMarketOrder, fillLimitOrder, parseSymbol } from './fill-engine.js';
12
+ import { MAX_BOOK_DRIFT_BPS } from './realistic-fills.js';
12
13
  import { updateMfe } from '../mfe.js';
13
14
  import { computeInvalidationHit } from '../pinned-plan.js';
14
15
  const TAG = 'simulator';
@@ -805,7 +806,10 @@ export class ExchangeSimulator extends EventEmitter {
805
806
  logger.info(TAG, `Market order filled: ${order.side} ${order.amount} ${order.symbol} @ ${result.order.average}` +
806
807
  ` (decision: ${eq.decisionPrice.toFixed(2)}, slippage: ${eq.slippageBps.toFixed(2)}bps` +
807
808
  `, latency: ${eq.latencyMs.toFixed(0)}ms, fee: ${eq.feeRate * 100}%` +
808
- `, book: ${eq.bookDepthAvailable ? `${eq.bookLevelsConsumed} levels` : 'unavailable'})`);
809
+ `, book: ${eq.bookDepthAvailable ? `${eq.bookLevelsConsumed} levels` : 'unavailable'}` +
810
+ `${eq.bookDriftBps !== undefined && Math.abs(eq.bookDriftBps) > MAX_BOOK_DRIFT_BPS
811
+ ? `, stale book ${eq.bookDriftBps.toFixed(0)}bps off the decision price: fill anchored`
812
+ : ''})`);
809
813
  }
810
814
  else {
811
815
  logger.info(TAG, `Market order filled: ${order.side} ${order.amount} ${order.symbol} @ ${result.order.average}`);
@@ -1,4 +1,18 @@
1
1
  import type { OrderBookDepth, SimulationConfig, ExecutionQuality } from './types.js';
2
+ /**
3
+ * How far (bps) the cached book's mid may sit from the decision price before
4
+ * the fill is anchored to the decision price instead of the raw book VWAP.
5
+ *
6
+ * The book is refreshed only when a tool fetches it (tools/helpers.ts
7
+ * fetchOrderBook, typically at entry), so a stop or target hours later used to
8
+ * fill against an hours-old book: an ADA/USDT short stop with a 0.2123 breach
9
+ * mark filled at 0.207103, 245 bps in the trader's favour, booking a -1.04R
10
+ * loss as -0.02R (2026-09-16 audit). Inside the band the book is fresh enough
11
+ * to price the fill directly (unchanged behaviour); outside it the book only
12
+ * shapes the impact (VWAP vs mid), applied to the price the decision was
13
+ * actually made on.
14
+ */
15
+ export declare const MAX_BOOK_DRIFT_BPS = 10;
2
16
  /**
3
17
  * Walk the order book to compute a volume-weighted average fill price.
4
18
  *
@@ -45,6 +59,8 @@ export declare function getFeeRate(orderType: 'market' | 'limit', config: Simula
45
59
  *
46
60
  * Combines: orderbook VWAP + latency drift + appropriate fee rate.
47
61
  * Falls back to simple random slippage if no orderbook is available.
62
+ * A book whose mid has drifted more than MAX_BOOK_DRIFT_BPS from the decision
63
+ * price is stale: its impact is kept, the level is re-anchored.
48
64
  *
49
65
  * @returns fillPrice and ExecutionQuality metrics
50
66
  */
@@ -1,6 +1,20 @@
1
1
  // Phase 9a: Realistic fill simulation.
2
2
  // Order book-aware VWAP fills, latency modeling, maker/taker fees.
3
3
  import { DEFAULT_SIMULATION_CONFIG, priceToBps } from './types.js';
4
+ /**
5
+ * How far (bps) the cached book's mid may sit from the decision price before
6
+ * the fill is anchored to the decision price instead of the raw book VWAP.
7
+ *
8
+ * The book is refreshed only when a tool fetches it (tools/helpers.ts
9
+ * fetchOrderBook, typically at entry), so a stop or target hours later used to
10
+ * fill against an hours-old book: an ADA/USDT short stop with a 0.2123 breach
11
+ * mark filled at 0.207103, 245 bps in the trader's favour, booking a -1.04R
12
+ * loss as -0.02R (2026-09-16 audit). Inside the band the book is fresh enough
13
+ * to price the fill directly (unchanged behaviour); outside it the book only
14
+ * shapes the impact (VWAP vs mid), applied to the price the decision was
15
+ * actually made on.
16
+ */
17
+ export const MAX_BOOK_DRIFT_BPS = 10;
4
18
  /**
5
19
  * Walk the order book to compute a volume-weighted average fill price.
6
20
  *
@@ -92,6 +106,8 @@ export function getFeeRate(orderType, config) {
92
106
  *
93
107
  * Combines: orderbook VWAP + latency drift + appropriate fee rate.
94
108
  * Falls back to simple random slippage if no orderbook is available.
109
+ * A book whose mid has drifted more than MAX_BOOK_DRIFT_BPS from the decision
110
+ * price is stale: its impact is kept, the level is re-anchored.
95
111
  *
96
112
  * @returns fillPrice and ExecutionQuality metrics
97
113
  */
@@ -103,6 +119,7 @@ export function computeRealisticMarketFill(side, amount, decisionPrice, orderboo
103
119
  let latencyImpactBps;
104
120
  let bookLevelsConsumed;
105
121
  let bookDepthAvailable;
122
+ let bookDriftBps;
106
123
  if (orderbook && orderbook.asks.length > 0 && orderbook.bids.length > 0) {
107
124
  // Book-aware VWAP fill
108
125
  const { vwap, levelsConsumed } = computeBookAwareFillPrice(side, amount, orderbook);
@@ -113,8 +130,14 @@ export function computeRealisticMarketFill(side, amount, decisionPrice, orderboo
113
130
  marketImpactBps = priceToBps(vwap, midPrice);
114
131
  // For sells, impact is negative (received less), so take absolute for the metric
115
132
  // but keep signed for the actual price
116
- // Apply latency drift on top of VWAP
117
- const latencyResult = applyLatencyDrift(vwap, side, latencyMs, volFactor);
133
+ // A book that has drifted from the decision price is stale: keep its
134
+ // impact, re-anchor the level (see MAX_BOOK_DRIFT_BPS).
135
+ bookDriftBps = priceToBps(midPrice, decisionPrice);
136
+ const level = Math.abs(bookDriftBps) > MAX_BOOK_DRIFT_BPS
137
+ ? decisionPrice * (1 + marketImpactBps / 10_000)
138
+ : vwap;
139
+ // Apply latency drift on top of the (possibly re-anchored) VWAP
140
+ const latencyResult = applyLatencyDrift(level, side, latencyMs, volFactor);
118
141
  fillPrice = latencyResult.adjustedPrice;
119
142
  latencyImpactBps = latencyResult.latencyImpactBps;
120
143
  }
@@ -147,6 +170,7 @@ export function computeRealisticMarketFill(side, amount, decisionPrice, orderboo
147
170
  feePaid,
148
171
  bookLevelsConsumed,
149
172
  bookDepthAvailable,
173
+ ...(bookDriftBps !== undefined ? { bookDriftBps } : {}),
150
174
  };
151
175
  return { fillPrice, executionQuality };
152
176
  }
@@ -39,6 +39,10 @@ export interface ExecutionQuality {
39
39
  feePaid: number;
40
40
  bookLevelsConsumed: number;
41
41
  bookDepthAvailable: boolean;
42
+ /** Signed bps between the cached book's mid and the decision price at fill
43
+ * time. Beyond MAX_BOOK_DRIFT_BPS the fill was anchored to the decision
44
+ * price (stale book). Absent when no book was available. */
45
+ bookDriftBps?: number;
42
46
  /** Age of the quote the fill priced against (fill time − ticker.timestamp).
43
47
  * Surfaces feed staleness (issue #202); absent on records from before the
44
48
  * field existed or when the ticker carried no usable timestamp. */