@reefclaw/connect 0.1.39 → 0.1.41

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 (32) hide show
  1. package/README.md +23 -0
  2. package/assets/bridge/config.d.ts +7 -0
  3. package/assets/bridge/config.js +13 -0
  4. package/assets/bridge/gateway/agent-scope.d.ts +30 -0
  5. package/assets/bridge/gateway/agent-scope.js +67 -0
  6. package/assets/bridge/gateway/gateway-config.d.ts +18 -3
  7. package/assets/bridge/gateway/gateway-config.js +42 -4
  8. package/assets/bridge/gateway/gateway-ws-client.d.ts +24 -0
  9. package/assets/bridge/gateway/gateway-ws-client.js +87 -4
  10. package/assets/bridge/index.js +35 -30
  11. package/assets/bridge/providers/connector-update.d.ts +43 -0
  12. package/assets/bridge/providers/connector-update.js +149 -0
  13. package/assets/bridge/providers/gateway.d.ts +10 -5
  14. package/assets/bridge/providers/gateway.js +96 -79
  15. package/assets/plugin/exchange-adapter.d.ts +7 -2
  16. package/assets/plugin/index.js +12 -0
  17. package/assets/plugin/ingest/readiness-reporter.d.ts +8 -1
  18. package/assets/plugin/ingest/readiness-reporter.js +7 -3
  19. package/assets/plugin/live/stop-watcher.js +7 -1
  20. package/assets/plugin/openclaw.plugin.json +1 -1
  21. package/assets/plugin/paper-adapter.d.ts +1 -1
  22. package/assets/plugin/paper-adapter.js +2 -2
  23. package/assets/plugin/plugin-version.d.ts +21 -0
  24. package/assets/plugin/plugin-version.js +58 -0
  25. package/assets/plugin/simulator/exchange-simulator.js +5 -1
  26. package/assets/plugin/simulator/realistic-fills.d.ts +16 -0
  27. package/assets/plugin/simulator/realistic-fills.js +26 -2
  28. package/assets/plugin/simulator/types.d.ts +4 -0
  29. package/assets/shared/readiness.d.ts +5 -0
  30. package/dist/agents.js +92 -0
  31. package/dist/cli.js +16 -0
  32. package/package.json +5 -2
package/README.md ADDED
@@ -0,0 +1,23 @@
1
+ # @reefclaw/connect
2
+
3
+ One-command installer that connects an OpenClaw agent to [ReefClaw](https://reefclaw.com) for paper trading. No exchange keys are needed for paper mode.
4
+
5
+ ```
6
+ npx -y @reefclaw/connect
7
+ ```
8
+
9
+ It places two things next to your OpenClaw install: the ReefClaw trading plugin (tools the agent calls) and the bridge (a small daemon that links your gateway to the ReefClaw dashboard over an outbound WebSocket). Re-running the command updates both in place, then restarts the gateway.
10
+
11
+ Requirements: Node 20 or newer for the installer itself, and whatever your OpenClaw version needs (OpenClaw 2026.9.3 and later requires Node 24).
12
+
13
+ ## Notes for self-hosters
14
+
15
+ **One gateway per trading agent.** OpenClaw broadcasts every agent's run events to every operator connection, and an operator token can't be scoped to a single agent. The bridge keeps only its own agent's runs. It follows the gateway's default agent (the one the installer set up) with no configuration; if your trading agent is a different one, set `OPENCLAW_AGENT_ID` or `"agentId"` in `~/.reefclaw/plugin-config.json`. The cleanest setup for a gateway that hosts other agents is still a separate gateway instance for the trading agent.
16
+
17
+ **One-click updates from the dashboard.** The "Update connector" button asks the bridge to run `npx -y @reefclaw/connect@latest` in a gateway terminal on your box. The command is a fixed constant; nothing from the dashboard or relay is interpolated into it. If you'd rather run updates yourself (for example because you carry local patches), switch it off per host with `REEFCLAW_ALLOW_CONNECTOR_UPDATE=0` in the bridge's environment or `"connectorUpdate": "off"` in `~/.reefclaw/plugin-config.json`. Setting `gateway.terminal.enabled=false` in `openclaw.json` disables it at the gateway level too. The dashboard then shows the manual command instead.
18
+
19
+ **Paper fills.** The paper engine prices market fills off a cached order book. Since 0.1.41 a fill whose book has drifted more than 10 bps from the decision price is anchored to the decision price (stop breaches and target hits used to fill at entry-time levels).
20
+
21
+ ## Reporting issues
22
+
23
+ Mail support@reefclaw.com. Security reports are welcome at the same address; please keep them at the design level until we've replied.
@@ -20,6 +20,13 @@ export declare function writeOpenClawConfig(token: string, userId?: string, rela
20
20
  * guess. An empty/absent list is treated as "no evidence" (not a downgrade
21
21
  * trigger): some builds omit the field, and acting on silence would recreate
22
22
  * the speculative relaxation this replaced. */
23
+ /** True when the gateway REJECTED the connect handshake because the client
24
+ * carries no device identity — OpenClaw >= 2026.9 closes a control-UI client
25
+ * (the bridge presents as `openclaw-tui`) with 1008 "control ui requires
26
+ * device identity (use HTTPS or localhost secure context)" on an unrelaxed
27
+ * gateway. Because the handshake never completes, handshakeLacksWriteScope
28
+ * never gets to run — this is the equivalent proven-need signal for it. */
29
+ export declare function isDeviceIdentityRejection(code: number | undefined, reason: string | undefined): boolean;
23
30
  export declare function handshakeLacksWriteScope(scopes: readonly string[] | undefined): boolean;
24
31
  /**
25
32
  * LAST RESORT: relax the local gateway's device-identity auth because a
@@ -147,6 +147,19 @@ function applyGatewayAuthRelaxation(config) {
147
147
  * guess. An empty/absent list is treated as "no evidence" (not a downgrade
148
148
  * trigger): some builds omit the field, and acting on silence would recreate
149
149
  * the speculative relaxation this replaced. */
150
+ /** True when the gateway REJECTED the connect handshake because the client
151
+ * carries no device identity — OpenClaw >= 2026.9 closes a control-UI client
152
+ * (the bridge presents as `openclaw-tui`) with 1008 "control ui requires
153
+ * device identity (use HTTPS or localhost secure context)" on an unrelaxed
154
+ * gateway. Because the handshake never completes, handshakeLacksWriteScope
155
+ * never gets to run — this is the equivalent proven-need signal for it. */
156
+ export function isDeviceIdentityRejection(code, reason) {
157
+ if (typeof reason !== 'string' || reason.length === 0)
158
+ return false;
159
+ if (code !== undefined && code !== 1008 && code !== 4000)
160
+ return false;
161
+ return /device identity/i.test(reason);
162
+ }
150
163
  export function handshakeLacksWriteScope(scopes) {
151
164
  if (!Array.isArray(scopes) || scopes.length === 0)
152
165
  return false;
@@ -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.
@@ -1,5 +1,9 @@
1
1
  import { type ReconnectConfig as BaseReconnectConfig } from '../utils/reconnect.js';
2
2
  import type { GatewayConfig } from './gateway-config.js';
3
+ export type GatewayClientId = 'gateway-client' | 'openclaw-tui';
4
+ export declare const DEFAULT_CLIENT_IDENTITIES: readonly GatewayClientId[];
5
+ /** The gateway's wording when a control-UI client has no device identity. */
6
+ export declare function isDeviceIdentityRejectionMessage(message: string | undefined | null): boolean;
3
7
  interface ReconnectConfig extends BaseReconnectConfig {
4
8
  /** Fast-backoff attempts before falling back to the slow retry loop */
5
9
  maxFastAttempts: number;
@@ -47,6 +51,7 @@ export type GatewayWsEventPayload = {
47
51
  seq?: number;
48
52
  sessionKey?: string;
49
53
  sessionId?: string;
54
+ agentId?: string;
50
55
  };
51
56
  presence: {
52
57
  payload: unknown;
@@ -80,8 +85,21 @@ export declare class GatewayWsClient {
80
85
  private readonly gatewayUrl;
81
86
  private readonly gatewayToken;
82
87
  private readonly requestAdminScope;
88
+ /** Identities to try, in order (see the client-identity note above). */
89
+ private readonly identities;
90
+ private identityIndex;
91
+ /** Scope the handshake must grant, else the next identity is tried. */
92
+ private readonly requireScope;
93
+ /** Set when the current socket is being closed ON PURPOSE to retry the
94
+ * handshake under the next identity: the close handler then reconnects
95
+ * immediately (no backoff, no 'disconnected' event). */
96
+ private identitySwitchPending;
83
97
  constructor(config: Pick<GatewayConfig, 'gatewayUrl' | 'gatewayToken'> & {
84
98
  requestAdminScope?: boolean;
99
+ /** Override the identity ladder (e.g. `[winningId]` for a side session). */
100
+ identities?: readonly GatewayClientId[];
101
+ /** Scope the handshake must grant (default operator.write); null = any. */
102
+ requireScope?: string | null;
85
103
  }, reconnect?: Partial<ReconnectConfig>);
86
104
  /** Start the WebSocket connection and handshake */
87
105
  connect(): void;
@@ -100,6 +118,12 @@ export declare class GatewayWsClient {
100
118
  getState(): GatewayWsState;
101
119
  /** Get the hello-ok snapshot from the last successful connection */
102
120
  getHelloOk(): HelloOkPayload | null;
121
+ /** The client identity currently in use (the one the gateway admitted, once connected). */
122
+ getClientId(): GatewayClientId;
123
+ private hasNextIdentity;
124
+ /** Close the current socket and retry the handshake under the next identity. */
125
+ private switchIdentity;
126
+ private completeIdentitySwitch;
103
127
  /** Get stored device token (for future connects) */
104
128
  getDeviceToken(): string | null;
105
129
  private handleMessage;
@@ -28,8 +28,12 @@ function isLoopbackHost(hostAndRest) {
28
28
  // gets `1002 protocol mismatch` there and the bridge can never connect).
29
29
  const MIN_PROTOCOL_VERSION = 3;
30
30
  const MAX_PROTOCOL_VERSION = 4;
31
- const CLIENT_ID = 'openclaw-tui';
31
+ export const DEFAULT_CLIENT_IDENTITIES = ['gateway-client', 'openclaw-tui'];
32
32
  const CLIENT_VERSION = '0.1.0';
33
+ /** The gateway's wording when a control-UI client has no device identity. */
34
+ export function isDeviceIdentityRejectionMessage(message) {
35
+ return typeof message === 'string' && /device identity/i.test(message);
36
+ }
33
37
  // Mirrors the relay connector's proven shape (see connector.ts DEFAULT_RECONNECT):
34
38
  // a bounded fast-backoff burst, then a slow retry that NEVER gives up.
35
39
  //
@@ -76,8 +80,20 @@ export class GatewayWsClient {
76
80
  gatewayUrl;
77
81
  gatewayToken;
78
82
  requestAdminScope;
83
+ /** Identities to try, in order (see the client-identity note above). */
84
+ identities;
85
+ identityIndex = 0;
86
+ /** Scope the handshake must grant, else the next identity is tried. */
87
+ requireScope;
88
+ /** Set when the current socket is being closed ON PURPOSE to retry the
89
+ * handshake under the next identity: the close handler then reconnects
90
+ * immediately (no backoff, no 'disconnected' event). */
91
+ identitySwitchPending = false;
79
92
  constructor(config, reconnect) {
80
93
  this.requestAdminScope = config.requestAdminScope === true;
94
+ this.identities =
95
+ config.identities && config.identities.length > 0 ? [...config.identities] : [...DEFAULT_CLIENT_IDENTITIES];
96
+ this.requireScope = config.requireScope === undefined ? 'operator.write' : config.requireScope;
81
97
  // Convert to WebSocket URL: http→ws, https→wss. A bare host:port defaults
82
98
  // by destination: loopback → ws:// (the normal localhost gateway), anything
83
99
  // else → wss:// — a remote default must never silently downgrade to
@@ -126,9 +142,22 @@ export class GatewayWsClient {
126
142
  logger.info(TAG, `WebSocket closed: ${code} ${reasonStr}`);
127
143
  this.ws = null;
128
144
  this.clearStaleTimer();
145
+ if (this.identitySwitchPending && !this.destroyed) {
146
+ // Closed on purpose to retry the handshake under the next identity:
147
+ // no 'disconnected' event, no backoff.
148
+ this.completeIdentitySwitch();
149
+ return;
150
+ }
129
151
  this.rejectAllPending('Connection closed');
130
152
  if (this.destroyed)
131
153
  return;
154
+ // A device-identity rejection that arrived only as a close frame (no
155
+ // error response): still worth the next identity.
156
+ if (code === 1008 && isDeviceIdentityRejectionMessage(reasonStr) && this.hasNextIdentity()) {
157
+ this.identitySwitchPending = true;
158
+ this.completeIdentitySwitch();
159
+ return;
160
+ }
132
161
  const wasConnected = this.state === 'connected' || this.state === 'handshaking';
133
162
  if (wasConnected) {
134
163
  this.emit('disconnected', { code, reason: reasonStr });
@@ -202,6 +231,40 @@ export class GatewayWsClient {
202
231
  getHelloOk() {
203
232
  return this.lastHelloOk;
204
233
  }
234
+ /** The client identity currently in use (the one the gateway admitted, once connected). */
235
+ getClientId() {
236
+ return this.identities[this.identityIndex];
237
+ }
238
+ hasNextIdentity() {
239
+ return this.identityIndex + 1 < this.identities.length;
240
+ }
241
+ /** Close the current socket and retry the handshake under the next identity. */
242
+ switchIdentity(why) {
243
+ const from = this.getClientId();
244
+ const to = this.identities[this.identityIndex + 1];
245
+ logger.warn(TAG, `Gateway would not take client id ${from} (${why}) — retrying the handshake as ${to}`);
246
+ // No rejectAllPending here: the handshake's pending entry is already gone
247
+ // (handleResponse deletes it before invoking resolve/reject, and this can
248
+ // run from inside that reject), so the close handler does the rest.
249
+ this.identitySwitchPending = true;
250
+ const ws = this.ws;
251
+ if (ws && ws.readyState === WebSocket.OPEN) {
252
+ ws.close(4000, 'identity switch');
253
+ }
254
+ else {
255
+ this.completeIdentitySwitch();
256
+ }
257
+ }
258
+ completeIdentitySwitch() {
259
+ this.identitySwitchPending = false;
260
+ this.identityIndex++;
261
+ this.attempt = 0;
262
+ this.ws = null;
263
+ // connect() refuses to run while 'handshaking' — the state the rejected
264
+ // attempt left behind.
265
+ this.setState('disconnected');
266
+ this.connect();
267
+ }
205
268
  /** Get stored device token (for future connects) */
206
269
  getDeviceToken() {
207
270
  return this.deviceToken;
@@ -281,6 +344,9 @@ export class GatewayWsClient {
281
344
  seq: frame.seq,
282
345
  ...(typeof p.sessionKey === 'string' ? { sessionKey: p.sessionKey } : {}),
283
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 } : {}),
284
350
  });
285
351
  }
286
352
  break;
@@ -321,7 +387,7 @@ export class GatewayWsClient {
321
387
  minProtocol: MIN_PROTOCOL_VERSION,
322
388
  maxProtocol: MAX_PROTOCOL_VERSION,
323
389
  client: {
324
- id: CLIENT_ID,
390
+ id: this.getClientId(),
325
391
  version: CLIENT_VERSION,
326
392
  platform: 'node',
327
393
  mode: 'backend',
@@ -357,7 +423,12 @@ export class GatewayWsClient {
357
423
  resolve: () => { },
358
424
  reject: (err) => {
359
425
  logger.error(TAG, `Handshake failed: ${err.message}`);
360
- this.ws?.close(4000, 'Handshake rejected');
426
+ if (isDeviceIdentityRejectionMessage(err.message) && this.hasNextIdentity()) {
427
+ this.switchIdentity(err.message);
428
+ return;
429
+ }
430
+ // Carry the gateway's reason (close reasons are capped at 123 bytes).
431
+ this.ws?.close(4000, `Handshake rejected: ${err.message}`.slice(0, 120));
361
432
  },
362
433
  timer,
363
434
  });
@@ -369,7 +440,19 @@ export class GatewayWsClient {
369
440
  * Extracts policy, snapshot, and device token.
370
441
  */
371
442
  handleHelloOk(payload) {
372
- logger.info(TAG, `Connected to gateway (protocol=${payload.protocol})`);
443
+ // Older builds admit `gateway-client` but strip its scopes: without the
444
+ // required scope this session is useless — retry as the next identity
445
+ // rather than accept a connection that cannot trade.
446
+ const granted = payload.auth?.scopes;
447
+ if (this.requireScope &&
448
+ Array.isArray(granted) &&
449
+ granted.length > 0 &&
450
+ !granted.includes(this.requireScope) &&
451
+ this.hasNextIdentity()) {
452
+ this.switchIdentity(`granted [${granted.join(', ')}] without ${this.requireScope}`);
453
+ return;
454
+ }
455
+ logger.info(TAG, `Connected to gateway (protocol=${payload.protocol}, client=${this.getClientId()})`);
373
456
  this.lastHelloOk = payload;
374
457
  this.attempt = 0; // Reset reconnect counter
375
458
  this.slowRetryActive = false; // Back on the fast budget for the next drop
@@ -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
@@ -87,3 +90,43 @@ export declare function startConnectorUpdate(rpc: TerminalRpc, opts: StartOption
87
90
  export declare function readTerminalText(rpc: TerminalRpc, sessionId: string): Promise<string | null>;
88
91
  /** Best-effort PTY cleanup. Never throws — cleanup failure must not mask an outcome. */
89
92
  export declare function closeTerminal(rpc: TerminalRpc, sessionId: string): Promise<void>;
93
+ /** The subset of GatewayWsClient the admin session needs — injected so the
94
+ * handshake logic is unit-testable with a fake client. */
95
+ export interface AdminSessionClient {
96
+ connect(): void;
97
+ destroy(): void;
98
+ on(event: 'connected', listener: (hello: {
99
+ auth?: {
100
+ scopes?: string[];
101
+ };
102
+ } | null | undefined) => void): void;
103
+ on(event: 'failed', listener: (payload: {
104
+ reason?: string;
105
+ } | undefined) => void): void;
106
+ sendRpc(method: string, params?: Record<string, unknown>): Promise<unknown>;
107
+ }
108
+ export declare function hasAdminScope(scopes: readonly string[] | undefined | null): boolean;
109
+ export declare function describeMissingAdminScope(granted: readonly string[] | undefined | null): ConnectorUpdateOutcome;
110
+ export type AdminSessionResult = {
111
+ ok: true;
112
+ scopes: string[];
113
+ } | {
114
+ ok: false;
115
+ outcome: ConnectorUpdateOutcome;
116
+ };
117
+ /** Connect a fresh admin-scoped client and wait for hello-ok. Resolves with the
118
+ * granted scopes, or a blocked/failed outcome (client already destroyed). */
119
+ export declare function openAdminGatewaySession(client: AdminSessionClient, timeoutMs?: number): Promise<AdminSessionResult>;
120
+ export interface RunOptions {
121
+ pollMs?: number;
122
+ maxPolls?: number;
123
+ /** Injected for tests. */
124
+ sleep?: (ms: number) => Promise<void>;
125
+ }
126
+ /**
127
+ * Poll the PTY and emit progress until the command finishes or the gateway
128
+ * restart takes the session down. Returns the final outcome. A single failed
129
+ * read is not proof the gateway went away (transient RPC error under install
130
+ * load) — two in a row are required before declaring the restart.
131
+ */
132
+ export declare function runConnectorUpdate(rpc: TerminalRpc, sessionId: string, emit: (outcome: ConnectorUpdateOutcome) => void, opts?: RunOptions): Promise<ConnectorUpdateOutcome>;