@reefclaw/openclaw-plugin 0.1.27 → 0.1.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.gitignore +1 -0
- package/bridge/config.d.ts +7 -0
- package/bridge/config.js +13 -0
- package/bridge/gateway/agent-scope.d.ts +30 -0
- package/bridge/gateway/agent-scope.js +67 -0
- package/bridge/gateway/gateway-config.d.ts +18 -3
- package/bridge/gateway/gateway-config.js +42 -4
- package/bridge/gateway/gateway-ws-client.d.ts +24 -0
- package/bridge/gateway/gateway-ws-client.js +87 -4
- package/bridge/index.js +35 -30
- package/bridge/providers/connector-update.d.ts +43 -0
- package/bridge/providers/connector-update.js +149 -0
- package/bridge/providers/gateway.d.ts +10 -5
- package/bridge/providers/gateway.js +96 -79
- package/exchange-adapter.d.ts +7 -2
- package/index.js +12 -0
- package/ingest/readiness-reporter.d.ts +8 -1
- package/ingest/readiness-reporter.js +7 -3
- package/live/stop-watcher.js +7 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/paper-adapter.d.ts +1 -1
- package/paper-adapter.js +2 -2
- package/plugin-version.d.ts +21 -0
- package/plugin-version.js +58 -0
- package/release-notes.json +6 -0
- package/simulator/exchange-simulator.js +5 -1
- package/simulator/realistic-fills.d.ts +16 -0
- package/simulator/realistic-fills.js +26 -2
- package/simulator/types.d.ts +4 -0
package/.gitignore
CHANGED
package/bridge/config.d.ts
CHANGED
|
@@ -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
|
package/bridge/config.js
CHANGED
|
@@ -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
|
|
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:
|
|
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.
|
|
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
|
-
|
|
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
|
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
|
-
--
|
|
136
|
-
--
|
|
137
|
-
--
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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>;
|