@reefclaw/openclaw-plugin 0.1.26 → 0.1.28
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/gateway-ws-client.d.ts +23 -0
- package/bridge/gateway/gateway-ws-client.js +84 -4
- package/bridge/providers/connector-update.d.ts +40 -0
- package/bridge/providers/connector-update.js +123 -0
- package/bridge/providers/gateway.d.ts +5 -5
- package/bridge/providers/gateway.js +64 -76
- package/index.js +19 -0
- package/ingest/readiness-reporter.d.ts +8 -1
- package/ingest/readiness-reporter.js +7 -3
- package/ingest/reconcile-db-vs-exchange.d.ts +13 -0
- package/ingest/reconcile-db-vs-exchange.js +23 -2
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/paper-adapter.js +8 -0
- package/plugin-version.d.ts +21 -0
- package/plugin-version.js +58 -0
- package/release-notes.json +5 -0
- package/simulator/exchange-simulator.d.ts +7 -0
- package/simulator/exchange-simulator.js +30 -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;
|
|
@@ -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;
|
|
@@ -80,8 +84,21 @@ export declare class GatewayWsClient {
|
|
|
80
84
|
private readonly gatewayUrl;
|
|
81
85
|
private readonly gatewayToken;
|
|
82
86
|
private readonly requestAdminScope;
|
|
87
|
+
/** Identities to try, in order (see the client-identity note above). */
|
|
88
|
+
private readonly identities;
|
|
89
|
+
private identityIndex;
|
|
90
|
+
/** Scope the handshake must grant, else the next identity is tried. */
|
|
91
|
+
private readonly requireScope;
|
|
92
|
+
/** Set when the current socket is being closed ON PURPOSE to retry the
|
|
93
|
+
* handshake under the next identity: the close handler then reconnects
|
|
94
|
+
* immediately (no backoff, no 'disconnected' event). */
|
|
95
|
+
private identitySwitchPending;
|
|
83
96
|
constructor(config: Pick<GatewayConfig, 'gatewayUrl' | 'gatewayToken'> & {
|
|
84
97
|
requestAdminScope?: boolean;
|
|
98
|
+
/** Override the identity ladder (e.g. `[winningId]` for a side session). */
|
|
99
|
+
identities?: readonly GatewayClientId[];
|
|
100
|
+
/** Scope the handshake must grant (default operator.write); null = any. */
|
|
101
|
+
requireScope?: string | null;
|
|
85
102
|
}, reconnect?: Partial<ReconnectConfig>);
|
|
86
103
|
/** Start the WebSocket connection and handshake */
|
|
87
104
|
connect(): void;
|
|
@@ -100,6 +117,12 @@ export declare class GatewayWsClient {
|
|
|
100
117
|
getState(): GatewayWsState;
|
|
101
118
|
/** Get the hello-ok snapshot from the last successful connection */
|
|
102
119
|
getHelloOk(): HelloOkPayload | null;
|
|
120
|
+
/** The client identity currently in use (the one the gateway admitted, once connected). */
|
|
121
|
+
getClientId(): GatewayClientId;
|
|
122
|
+
private hasNextIdentity;
|
|
123
|
+
/** Close the current socket and retry the handshake under the next identity. */
|
|
124
|
+
private switchIdentity;
|
|
125
|
+
private completeIdentitySwitch;
|
|
103
126
|
/** Get stored device token (for future connects) */
|
|
104
127
|
getDeviceToken(): string | null;
|
|
105
128
|
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;
|
|
@@ -321,7 +384,7 @@ export class GatewayWsClient {
|
|
|
321
384
|
minProtocol: MIN_PROTOCOL_VERSION,
|
|
322
385
|
maxProtocol: MAX_PROTOCOL_VERSION,
|
|
323
386
|
client: {
|
|
324
|
-
id:
|
|
387
|
+
id: this.getClientId(),
|
|
325
388
|
version: CLIENT_VERSION,
|
|
326
389
|
platform: 'node',
|
|
327
390
|
mode: 'backend',
|
|
@@ -357,7 +420,12 @@ export class GatewayWsClient {
|
|
|
357
420
|
resolve: () => { },
|
|
358
421
|
reject: (err) => {
|
|
359
422
|
logger.error(TAG, `Handshake failed: ${err.message}`);
|
|
360
|
-
this.
|
|
423
|
+
if (isDeviceIdentityRejectionMessage(err.message) && this.hasNextIdentity()) {
|
|
424
|
+
this.switchIdentity(err.message);
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
// Carry the gateway's reason (close reasons are capped at 123 bytes).
|
|
428
|
+
this.ws?.close(4000, `Handshake rejected: ${err.message}`.slice(0, 120));
|
|
361
429
|
},
|
|
362
430
|
timer,
|
|
363
431
|
});
|
|
@@ -369,7 +437,19 @@ export class GatewayWsClient {
|
|
|
369
437
|
* Extracts policy, snapshot, and device token.
|
|
370
438
|
*/
|
|
371
439
|
handleHelloOk(payload) {
|
|
372
|
-
|
|
440
|
+
// Older builds admit `gateway-client` but strip its scopes: without the
|
|
441
|
+
// required scope this session is useless — retry as the next identity
|
|
442
|
+
// rather than accept a connection that cannot trade.
|
|
443
|
+
const granted = payload.auth?.scopes;
|
|
444
|
+
if (this.requireScope &&
|
|
445
|
+
Array.isArray(granted) &&
|
|
446
|
+
granted.length > 0 &&
|
|
447
|
+
!granted.includes(this.requireScope) &&
|
|
448
|
+
this.hasNextIdentity()) {
|
|
449
|
+
this.switchIdentity(`granted [${granted.join(', ')}] without ${this.requireScope}`);
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
logger.info(TAG, `Connected to gateway (protocol=${payload.protocol}, client=${this.getClientId()})`);
|
|
373
453
|
this.lastHelloOk = payload;
|
|
374
454
|
this.attempt = 0; // Reset reconnect counter
|
|
375
455
|
this.slowRetryActive = false; // Back on the fast budget for the next drop
|
|
@@ -87,3 +87,43 @@ export declare function startConnectorUpdate(rpc: TerminalRpc, opts: StartOption
|
|
|
87
87
|
export declare function readTerminalText(rpc: TerminalRpc, sessionId: string): Promise<string | null>;
|
|
88
88
|
/** Best-effort PTY cleanup. Never throws — cleanup failure must not mask an outcome. */
|
|
89
89
|
export declare function closeTerminal(rpc: TerminalRpc, sessionId: string): Promise<void>;
|
|
90
|
+
/** The subset of GatewayWsClient the admin session needs — injected so the
|
|
91
|
+
* handshake logic is unit-testable with a fake client. */
|
|
92
|
+
export interface AdminSessionClient {
|
|
93
|
+
connect(): void;
|
|
94
|
+
destroy(): void;
|
|
95
|
+
on(event: 'connected', listener: (hello: {
|
|
96
|
+
auth?: {
|
|
97
|
+
scopes?: string[];
|
|
98
|
+
};
|
|
99
|
+
} | null | undefined) => void): void;
|
|
100
|
+
on(event: 'failed', listener: (payload: {
|
|
101
|
+
reason?: string;
|
|
102
|
+
} | undefined) => void): void;
|
|
103
|
+
sendRpc(method: string, params?: Record<string, unknown>): Promise<unknown>;
|
|
104
|
+
}
|
|
105
|
+
export declare function hasAdminScope(scopes: readonly string[] | undefined | null): boolean;
|
|
106
|
+
export declare function describeMissingAdminScope(granted: readonly string[] | undefined | null): ConnectorUpdateOutcome;
|
|
107
|
+
export type AdminSessionResult = {
|
|
108
|
+
ok: true;
|
|
109
|
+
scopes: string[];
|
|
110
|
+
} | {
|
|
111
|
+
ok: false;
|
|
112
|
+
outcome: ConnectorUpdateOutcome;
|
|
113
|
+
};
|
|
114
|
+
/** Connect a fresh admin-scoped client and wait for hello-ok. Resolves with the
|
|
115
|
+
* granted scopes, or a blocked/failed outcome (client already destroyed). */
|
|
116
|
+
export declare function openAdminGatewaySession(client: AdminSessionClient, timeoutMs?: number): Promise<AdminSessionResult>;
|
|
117
|
+
export interface RunOptions {
|
|
118
|
+
pollMs?: number;
|
|
119
|
+
maxPolls?: number;
|
|
120
|
+
/** Injected for tests. */
|
|
121
|
+
sleep?: (ms: number) => Promise<void>;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Poll the PTY and emit progress until the command finishes or the gateway
|
|
125
|
+
* restart takes the session down. Returns the final outcome. A single failed
|
|
126
|
+
* read is not proof the gateway went away (transient RPC error under install
|
|
127
|
+
* load) — two in a row are required before declaring the restart.
|
|
128
|
+
*/
|
|
129
|
+
export declare function runConnectorUpdate(rpc: TerminalRpc, sessionId: string, emit: (outcome: ConnectorUpdateOutcome) => void, opts?: RunOptions): Promise<ConnectorUpdateOutcome>;
|
|
@@ -85,6 +85,20 @@ export function describeOpenFailure(err) {
|
|
|
85
85
|
+ 'Update from a shell on the box instead: npx -y @reefclaw/connect@latest',
|
|
86
86
|
};
|
|
87
87
|
}
|
|
88
|
+
// Scope refusal: terminal.* requires operator.admin on OpenClaw >= 2026.7
|
|
89
|
+
// (verified in the 2026.9.4 method-scopes table). The steady-state bridge
|
|
90
|
+
// session runs on operator.read/write, so a bridge that predates the
|
|
91
|
+
// admin-session update (2026-09-14) is refused right here. Nothing changed
|
|
92
|
+
// on the box; the fix is one manual update, after which this is one click.
|
|
93
|
+
if (/operator\.admin|scope|forbidden|unauthori[sz]ed|not allowed|\b403\b/.test(lower)) {
|
|
94
|
+
return {
|
|
95
|
+
ok: false,
|
|
96
|
+
status: 'blocked',
|
|
97
|
+
message: 'The gateway refused to open a terminal for the connector (it needs the operator.admin scope). '
|
|
98
|
+
+ 'One-click updates need a connector released 2026-09-14 or later — update once by pasting the manual '
|
|
99
|
+
+ 'command to your agent or from a shell: npx -y @reefclaw/connect@latest. After that, updates are one click.',
|
|
100
|
+
};
|
|
101
|
+
}
|
|
88
102
|
return {
|
|
89
103
|
ok: false,
|
|
90
104
|
status: 'failed',
|
|
@@ -210,3 +224,112 @@ export async function readTerminalText(rpc, sessionId) {
|
|
|
210
224
|
export async function closeTerminal(rpc, sessionId) {
|
|
211
225
|
await rpc('terminal.close', { sessionId }).catch(() => undefined);
|
|
212
226
|
}
|
|
227
|
+
export function hasAdminScope(scopes) {
|
|
228
|
+
return Array.isArray(scopes) && scopes.includes('operator.admin');
|
|
229
|
+
}
|
|
230
|
+
export function describeMissingAdminScope(granted) {
|
|
231
|
+
const list = granted && granted.length > 0 ? granted.join(', ') : 'none';
|
|
232
|
+
return {
|
|
233
|
+
ok: false,
|
|
234
|
+
status: 'blocked',
|
|
235
|
+
message: `This gateway did not grant the connector the operator.admin scope it needs to open a terminal (granted: ${list}). `
|
|
236
|
+
+ 'One-click updates are unavailable on this box — update from a shell instead: npx -y @reefclaw/connect@latest',
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
/** Connect a fresh admin-scoped client and wait for hello-ok. Resolves with the
|
|
240
|
+
* granted scopes, or a blocked/failed outcome (client already destroyed). */
|
|
241
|
+
export async function openAdminGatewaySession(client, timeoutMs = 20_000) {
|
|
242
|
+
const result = await new Promise((resolve) => {
|
|
243
|
+
const timer = setTimeout(() => resolve(new Error('timed out waiting for the gateway handshake')), timeoutMs);
|
|
244
|
+
client.on('connected', (hello) => {
|
|
245
|
+
clearTimeout(timer);
|
|
246
|
+
resolve(hello?.auth?.scopes ?? []);
|
|
247
|
+
});
|
|
248
|
+
client.on('failed', (payload) => {
|
|
249
|
+
clearTimeout(timer);
|
|
250
|
+
resolve(new Error(payload?.reason ?? 'gateway connection failed'));
|
|
251
|
+
});
|
|
252
|
+
client.connect();
|
|
253
|
+
});
|
|
254
|
+
if (result instanceof Error) {
|
|
255
|
+
client.destroy();
|
|
256
|
+
logger.warn(TAG, `admin session failed: ${result.message}`);
|
|
257
|
+
return {
|
|
258
|
+
ok: false,
|
|
259
|
+
outcome: { ok: false, status: 'failed', message: `Could not open an admin session on the gateway: ${result.message}` },
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
if (!hasAdminScope(result)) {
|
|
263
|
+
client.destroy();
|
|
264
|
+
logger.warn(TAG, `admin session granted [${result.join(', ')}] — operator.admin missing, update refused`);
|
|
265
|
+
return { ok: false, outcome: describeMissingAdminScope(result) };
|
|
266
|
+
}
|
|
267
|
+
logger.info(TAG, `admin session open (scopes: ${result.join(', ')})`);
|
|
268
|
+
return { ok: true, scopes: result };
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Poll the PTY and emit progress until the command finishes or the gateway
|
|
272
|
+
* restart takes the session down. Returns the final outcome. A single failed
|
|
273
|
+
* read is not proof the gateway went away (transient RPC error under install
|
|
274
|
+
* load) — two in a row are required before declaring the restart.
|
|
275
|
+
*/
|
|
276
|
+
export async function runConnectorUpdate(rpc, sessionId, emit, opts = {}) {
|
|
277
|
+
const pollMs = opts.pollMs ?? 1500;
|
|
278
|
+
// Generous: an npm install on a small VPS is slow (240 × 1.5 s = 6 min).
|
|
279
|
+
const maxPolls = opts.maxPolls ?? 240;
|
|
280
|
+
const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
281
|
+
let lastEmitted = '';
|
|
282
|
+
let consecutiveDeadReads = 0;
|
|
283
|
+
for (let i = 0; i < maxPolls; i++) {
|
|
284
|
+
await sleep(pollMs);
|
|
285
|
+
const screen = await readTerminalText(rpc, sessionId);
|
|
286
|
+
if (screen !== null)
|
|
287
|
+
consecutiveDeadReads = 0;
|
|
288
|
+
else if (++consecutiveDeadReads < 2)
|
|
289
|
+
continue;
|
|
290
|
+
if (screen === null) {
|
|
291
|
+
// Session (or the whole gateway) is gone. After a successful start this
|
|
292
|
+
// is the restart landing, not a failure.
|
|
293
|
+
const outcome = {
|
|
294
|
+
ok: true,
|
|
295
|
+
status: classifyTransportLoss(true),
|
|
296
|
+
sessionId,
|
|
297
|
+
output: lastEmitted,
|
|
298
|
+
message: 'The agent is restarting to load the new version. The dashboard reconnects on its own.',
|
|
299
|
+
};
|
|
300
|
+
emit(outcome);
|
|
301
|
+
return outcome;
|
|
302
|
+
}
|
|
303
|
+
if (screen !== lastEmitted) {
|
|
304
|
+
lastEmitted = screen;
|
|
305
|
+
emit({ ok: true, status: 'started', sessionId, output: screen, message: 'Updating…' });
|
|
306
|
+
}
|
|
307
|
+
if (isCredibleCompletion(screen)) {
|
|
308
|
+
const { exitCode } = parseDoneSentinel(screen);
|
|
309
|
+
const ok = exitCode === 0;
|
|
310
|
+
const outcome = {
|
|
311
|
+
ok,
|
|
312
|
+
status: 'completed',
|
|
313
|
+
sessionId,
|
|
314
|
+
exitCode,
|
|
315
|
+
output: screen,
|
|
316
|
+
message: ok
|
|
317
|
+
? 'Connector updated. The agent restarts to load it.'
|
|
318
|
+
: `The updater exited with code ${exitCode}. The connector was left as it was.`,
|
|
319
|
+
};
|
|
320
|
+
emit(outcome);
|
|
321
|
+
await closeTerminal(rpc, sessionId);
|
|
322
|
+
return outcome;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
const outcome = {
|
|
326
|
+
ok: false,
|
|
327
|
+
status: 'failed',
|
|
328
|
+
sessionId,
|
|
329
|
+
output: lastEmitted,
|
|
330
|
+
message: 'The update did not finish in time. Check the box directly before retrying.',
|
|
331
|
+
};
|
|
332
|
+
emit(outcome);
|
|
333
|
+
await closeTerminal(rpc, sessionId);
|
|
334
|
+
return outcome;
|
|
335
|
+
}
|
|
@@ -265,11 +265,6 @@ export declare class GatewayProvider implements OpenClawProvider {
|
|
|
265
265
|
updateConnector(args: {
|
|
266
266
|
acknowledgeOpenPositions?: boolean;
|
|
267
267
|
}): Promise<ConnectorUpdateOutcome>;
|
|
268
|
-
/**
|
|
269
|
-
* Poll the PTY and emit progress until the command finishes or the gateway
|
|
270
|
-
* restart takes the session (and us) down.
|
|
271
|
-
*/
|
|
272
|
-
private streamConnectorUpdate;
|
|
273
268
|
private emitConnectorUpdate;
|
|
274
269
|
/** Operator-only. Approval/balance status of the provisioned HL wallet. */
|
|
275
270
|
getHlAgentWalletStatus(): Promise<HlAgentWalletStatusOutcome>;
|
|
@@ -306,6 +301,11 @@ export declare class GatewayProvider implements OpenClawProvider {
|
|
|
306
301
|
* 2026-07-29), we read the scopes the gateway ACTUALLY granted and relax
|
|
307
302
|
* only when it demonstrably withheld the one we need. On the supported
|
|
308
303
|
* range the scope survives and nothing is written. */
|
|
304
|
+
private deviceIdentityRelaxationApplied;
|
|
305
|
+
/** Handshake rejected outright for missing device identity (see
|
|
306
|
+
* isDeviceIdentityRejection). Same escalation as the withheld-write-scope
|
|
307
|
+
* case, keyed on the rejection instead of on hello-ok scopes. */
|
|
308
|
+
private onHandshakeRejectedForDeviceIdentity;
|
|
309
309
|
private checkGrantedScopes;
|
|
310
310
|
private onWsConnected;
|
|
311
311
|
private onAgentEvent;
|
|
@@ -7,7 +7,7 @@ import { homedir } from 'os';
|
|
|
7
7
|
import { logger, formatError } from '../logger.js';
|
|
8
8
|
import { isTradingMode } from '../types.js';
|
|
9
9
|
import { deriveModelHealth } from '../model-health.js';
|
|
10
|
-
import { handshakeLacksWriteScope, relaxGatewayDeviceAuth } from '../config.js';
|
|
10
|
+
import { handshakeLacksWriteScope, isDeviceIdentityRejection, relaxGatewayDeviceAuth } from '../config.js';
|
|
11
11
|
import { toIntelSymbol } from '@reefclaw/shared';
|
|
12
12
|
import { GatewayHttpClient } from '../gateway/gateway-http-client.js';
|
|
13
13
|
import { GatewayWsClient } from '../gateway/gateway-ws-client.js';
|
|
@@ -19,7 +19,7 @@ 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,
|
|
22
|
+
import { startConnectorUpdate, runConnectorUpdate, openAdminGatewaySession, checkPositionGuard, } from './connector-update.js';
|
|
23
23
|
const TAG = 'gateway';
|
|
24
24
|
// ---- Day-start NAV persistence ----
|
|
25
25
|
// Persists sessionStartNav (the UTC-day P&L anchor) per date so Day P&L
|
|
@@ -749,87 +749,44 @@ export class GatewayProvider {
|
|
|
749
749
|
* `restarting` and the dashboard waits for the reconnect.
|
|
750
750
|
*/
|
|
751
751
|
async updateConnector(args) {
|
|
752
|
-
|
|
753
|
-
if (!ws) {
|
|
752
|
+
if (!this.wsClient) {
|
|
754
753
|
return { ok: false, status: 'failed', message: 'Not connected to the OpenClaw gateway.' };
|
|
755
754
|
}
|
|
756
|
-
const rpc = (method, params) => ws.sendRpc(method, params);
|
|
757
755
|
const openPositionCount = this.positions.filter((p) => Math.abs(Number(p.contracts) || 0) > 0).length;
|
|
758
|
-
const
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
if (
|
|
756
|
+
const guardArgs = { acknowledgeOpenPositions: args.acknowledgeOpenPositions, openPositionCount };
|
|
757
|
+
// Position guard FIRST — never open an admin session for a request that is
|
|
758
|
+
// going to be refused anyway.
|
|
759
|
+
const blocked = checkPositionGuard(guardArgs);
|
|
760
|
+
if (blocked)
|
|
761
|
+
return blocked;
|
|
762
|
+
// terminal.* is operator.admin-only (OpenClaw method-scopes, since 2026.7).
|
|
763
|
+
// The long-lived session runs on operator.read/write by design, so open a
|
|
764
|
+
// SEPARATE short-lived admin session for this update. Reconnects are
|
|
765
|
+
// effectively disabled: the gateway restart is supposed to kill it.
|
|
766
|
+
const admin = new GatewayWsClient({
|
|
767
|
+
gatewayUrl: this.config.gatewayUrl,
|
|
768
|
+
gatewayToken: this.config.gatewayToken,
|
|
769
|
+
requestAdminScope: true,
|
|
770
|
+
// The identity the long-lived session was admitted under; no ladder here.
|
|
771
|
+
identities: [this.wsClient.getClientId()],
|
|
772
|
+
requireScope: 'operator.admin',
|
|
773
|
+
}, { baseDelayMs: 60_000, maxDelayMs: 60_000, maxFastAttempts: 0, jitterFactor: 0, slowRetryMs: 3_600_000 });
|
|
774
|
+
const session = await openAdminGatewaySession(admin);
|
|
775
|
+
if (!session.ok)
|
|
776
|
+
return session.outcome;
|
|
777
|
+
const rpc = (method, params) => admin.sendRpc(method, params);
|
|
778
|
+
const started = await startConnectorUpdate(rpc, guardArgs);
|
|
779
|
+
if (started.status !== 'started' || !started.sessionId) {
|
|
780
|
+
admin.destroy();
|
|
763
781
|
return started;
|
|
782
|
+
}
|
|
764
783
|
// Stream progress in the background; the RPC returns as soon as the command
|
|
765
784
|
// is running so the browser is never left waiting on a minutes-long call.
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
/**
|
|
770
|
-
* Poll the PTY and emit progress until the command finishes or the gateway
|
|
771
|
-
* restart takes the session (and us) down.
|
|
772
|
-
*/
|
|
773
|
-
async streamConnectorUpdate(rpc, sessionId) {
|
|
774
|
-
const POLL_MS = 1500;
|
|
775
|
-
// Generous: an npm install on a small VPS is slow. The gateway restart
|
|
776
|
-
// normally ends this loop long before the cap.
|
|
777
|
-
const MAX_POLLS = 240;
|
|
778
|
-
let lastEmitted = '';
|
|
779
|
-
// A single failed read is not proof the gateway went away — it could be a
|
|
780
|
-
// transient RPC error under install load. Require two in a row before
|
|
781
|
-
// declaring the restart, so we do not flip the dashboard into "restarting"
|
|
782
|
-
// while the update is in fact still running.
|
|
783
|
-
let consecutiveDeadReads = 0;
|
|
784
|
-
for (let i = 0; i < MAX_POLLS; i++) {
|
|
785
|
-
await new Promise((r) => setTimeout(r, POLL_MS));
|
|
786
|
-
const screen = await readTerminalText(rpc, sessionId);
|
|
787
|
-
if (screen !== null)
|
|
788
|
-
consecutiveDeadReads = 0;
|
|
789
|
-
else if (++consecutiveDeadReads < 2)
|
|
790
|
-
continue;
|
|
791
|
-
if (screen === null) {
|
|
792
|
-
// Session (or the whole gateway) is gone. After a successful start this
|
|
793
|
-
// is the restart landing, not a failure.
|
|
794
|
-
const status = classifyTransportLoss(true);
|
|
795
|
-
this.emitConnectorUpdate({
|
|
796
|
-
ok: true,
|
|
797
|
-
status,
|
|
798
|
-
sessionId,
|
|
799
|
-
output: lastEmitted,
|
|
800
|
-
message: 'The agent is restarting to load the new version. The dashboard reconnects on its own.',
|
|
801
|
-
});
|
|
802
|
-
return;
|
|
803
|
-
}
|
|
804
|
-
if (screen !== lastEmitted) {
|
|
805
|
-
lastEmitted = screen;
|
|
806
|
-
this.emitConnectorUpdate({ ok: true, status: 'started', sessionId, output: screen, message: 'Updating…' });
|
|
807
|
-
}
|
|
808
|
-
if (isCredibleCompletion(screen)) {
|
|
809
|
-
const { exitCode } = parseDoneSentinel(screen);
|
|
810
|
-
const ok = exitCode === 0;
|
|
811
|
-
this.emitConnectorUpdate({
|
|
812
|
-
ok,
|
|
813
|
-
status: 'completed',
|
|
814
|
-
sessionId,
|
|
815
|
-
exitCode,
|
|
816
|
-
output: screen,
|
|
817
|
-
message: ok
|
|
818
|
-
? 'Connector updated. The agent restarts to load it.'
|
|
819
|
-
: `The updater exited with code ${exitCode}. The connector was left as it was.`,
|
|
820
|
-
});
|
|
821
|
-
await closeTerminal(rpc, sessionId);
|
|
822
|
-
return;
|
|
823
|
-
}
|
|
824
|
-
}
|
|
825
|
-
this.emitConnectorUpdate({
|
|
826
|
-
ok: false,
|
|
827
|
-
status: 'failed',
|
|
828
|
-
sessionId,
|
|
829
|
-
output: lastEmitted,
|
|
830
|
-
message: 'The update did not finish in time. Check the box directly before retrying.',
|
|
785
|
+
// The admin session is torn down when the loop ends, whichever way.
|
|
786
|
+
void runConnectorUpdate(rpc, started.sessionId, (outcome) => this.emitConnectorUpdate(outcome)).finally(() => {
|
|
787
|
+
admin.destroy();
|
|
831
788
|
});
|
|
832
|
-
|
|
789
|
+
return started;
|
|
833
790
|
}
|
|
834
791
|
emitConnectorUpdate(payload) {
|
|
835
792
|
this.fire('connectorUpdate', payload);
|
|
@@ -1283,6 +1240,12 @@ export class GatewayProvider {
|
|
|
1283
1240
|
this.wsClient.on('disconnected', ({ code, reason }) => {
|
|
1284
1241
|
logger.warn(TAG, `WS disconnected: ${code} ${reason}`);
|
|
1285
1242
|
this.poller?.pause();
|
|
1243
|
+
// OpenClaw >= 2026.9: an unrelaxed gateway rejects a control-UI client
|
|
1244
|
+
// without device identity AT THE HANDSHAKE, so checkGrantedScopes (which
|
|
1245
|
+
// needs a completed handshake) never runs and the bridge would reconnect
|
|
1246
|
+
// forever with no diagnosis. The rejection itself is the proven need.
|
|
1247
|
+
if (isDeviceIdentityRejection(code, reason))
|
|
1248
|
+
this.onHandshakeRejectedForDeviceIdentity(reason);
|
|
1286
1249
|
});
|
|
1287
1250
|
this.wsClient.on('reconnecting', ({ attempt, delayMs }) => {
|
|
1288
1251
|
logger.info(TAG, `WS reconnecting: attempt ${attempt}, delay ${Math.round(delayMs)}ms`);
|
|
@@ -1303,6 +1266,31 @@ export class GatewayProvider {
|
|
|
1303
1266
|
* 2026-07-29), we read the scopes the gateway ACTUALLY granted and relax
|
|
1304
1267
|
* only when it demonstrably withheld the one we need. On the supported
|
|
1305
1268
|
* range the scope survives and nothing is written. */
|
|
1269
|
+
deviceIdentityRelaxationApplied = false;
|
|
1270
|
+
/** Handshake rejected outright for missing device identity (see
|
|
1271
|
+
* isDeviceIdentityRejection). Same escalation as the withheld-write-scope
|
|
1272
|
+
* case, keyed on the rejection instead of on hello-ok scopes. */
|
|
1273
|
+
onHandshakeRejectedForDeviceIdentity(reason) {
|
|
1274
|
+
if (this.deviceIdentityRelaxationApplied)
|
|
1275
|
+
return;
|
|
1276
|
+
this.deviceIdentityRelaxationApplied = true;
|
|
1277
|
+
const tried = this.wsClient?.getClientId() ?? 'unknown';
|
|
1278
|
+
logger.error(TAG, `Gateway REJECTED the connector handshake under every client identity (last: ${tried}): ${reason}. ` +
|
|
1279
|
+
`OpenClaw >= 2026.9 admits only a local-backend client (gateway-client) or a device-paired one — ` +
|
|
1280
|
+
`the retired gateway.controlUi relaxation no longer applies — and this gateway did neither. The ` +
|
|
1281
|
+
`dashboard cannot reach this agent (no tools, no state, no updates) until the handshake completes.`);
|
|
1282
|
+
// Last resort for OLDER builds only: on 2026.9+ the flags are retired and
|
|
1283
|
+
// ignored (a config migration deletes them), so this is best-effort and
|
|
1284
|
+
// says so.
|
|
1285
|
+
const changed = relaxGatewayDeviceAuth(`the gateway rejected the connector handshake (${reason})`);
|
|
1286
|
+
if (changed) {
|
|
1287
|
+
logger.warn(TAG, 'Wrote the legacy gateway.controlUi relaxation flags in case this is an older OpenClaw build — ' +
|
|
1288
|
+
'RESTART the gateway to find out. On OpenClaw >= 2026.9 they are ignored: check the gateway ' +
|
|
1289
|
+
'auth config / device pairing instead.');
|
|
1290
|
+
}
|
|
1291
|
+
this.agentMode = 'ERROR';
|
|
1292
|
+
this.emitAgentState();
|
|
1293
|
+
}
|
|
1306
1294
|
checkGrantedScopes(helloOk) {
|
|
1307
1295
|
const scopes = helloOk.auth?.scopes;
|
|
1308
1296
|
if (!handshakeLacksWriteScope(scopes))
|
package/index.js
CHANGED
|
@@ -38,6 +38,7 @@ import { reconcileDbOpenVsExchange, startPeriodicDbReconcile } from './ingest/re
|
|
|
38
38
|
import { onStopWatcherClose } from './ingest/position-auto-capture.js';
|
|
39
39
|
import { ReentryTracker } from './portfolio/reentry-tracker.js';
|
|
40
40
|
import { startReadinessReporter } from './ingest/readiness-reporter.js';
|
|
41
|
+
import { resolvePluginInstallFacts } from './plugin-version.js';
|
|
41
42
|
import { IntelMicrostructureAssembler } from './live/microstructure-assembler.js';
|
|
42
43
|
import { recordPositionReviewsTool } from './tools/record-position-reviews.js';
|
|
43
44
|
import { getMyRecentReviewsTool } from './tools/get-my-recent-reviews.js';
|
|
@@ -1001,6 +1002,13 @@ const paperTradingPlugin = {
|
|
|
1001
1002
|
logger.warn(TAG, `State reload failed: ${formatError(err)}`);
|
|
1002
1003
|
}
|
|
1003
1004
|
};
|
|
1005
|
+
// Out-of-tool readers (periodic DB-vs-exchange sweep, stop-watcher) read
|
|
1006
|
+
// the PaperAdapter directly and never went through reloadState — in the
|
|
1007
|
+
// process that did not execute the entry they saw a stale book and posted
|
|
1008
|
+
// false `reconciler_observed_flat` closes seconds after every entry
|
|
1009
|
+
// (2026-09-14). Hook on the simulator so every adapter built from it
|
|
1010
|
+
// (boot + every reconnect path) refreshes before a read.
|
|
1011
|
+
simulator.setStateRefresher(reloadState);
|
|
1004
1012
|
// Read config: ReefClaw plugin config from ~/.reefclaw/plugin-config.json
|
|
1005
1013
|
// (OpenClaw's schema validation rejects custom keys in plugin entries)
|
|
1006
1014
|
let connectionToken = '';
|
|
@@ -2931,6 +2939,15 @@ const paperTradingPlugin = {
|
|
|
2931
2939
|
catch (err) {
|
|
2932
2940
|
logger.warn(TAG, `credential transport key setup failed (plaintext fallback stays available): ${formatError(err)}`);
|
|
2933
2941
|
}
|
|
2942
|
+
// Release version + install channel for the dashboard's update banner —
|
|
2943
|
+
// read from the manifest shipped next to index.js (stamped by both release
|
|
2944
|
+
// channels); the repo's unstamped manifest marks a source/dist deploy, which
|
|
2945
|
+
// the dashboard never nags (plugin/src/plugin-version.ts).
|
|
2946
|
+
const installFacts = resolvePluginInstallFacts({
|
|
2947
|
+
pluginRoot: dirname(fileURLToPath(import.meta.url)),
|
|
2948
|
+
connectorSupervisor: readPluginConfig().connectorSupervisor,
|
|
2949
|
+
});
|
|
2950
|
+
logger.info(TAG, `Plugin release ${installFacts.version ?? 'unknown (no stamped manifest)'} via ${installFacts.channel}`);
|
|
2934
2951
|
// Runs here once (guarded by the pluginInitialised early-return → once per
|
|
2935
2952
|
// process) + on an unref'd interval inside the reporter.
|
|
2936
2953
|
startReadinessReporter({
|
|
@@ -2939,6 +2956,8 @@ const paperTradingPlugin = {
|
|
|
2939
2956
|
venue,
|
|
2940
2957
|
publicApi: hlPublicApi ?? binanceApi,
|
|
2941
2958
|
toolCount: toolNames.length,
|
|
2959
|
+
pluginVersion: installFacts.version,
|
|
2960
|
+
installChannel: installFacts.channel,
|
|
2942
2961
|
// live_stop_protection (E2E audit #3): what would stop a losing live
|
|
2943
2962
|
// position. Deferred closure over the runtime so paper↔live flips and
|
|
2944
2963
|
// adapter swaps surface on the next 5-min report without a restart.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type ReadinessCheck, type ReadinessReport, type VenueId, type VenueReachabilityResult } from '@reefclaw/shared';
|
|
2
|
+
import type { InstallChannel } from '../plugin-version.js';
|
|
2
3
|
/** Who froze the loop. 'unknown' when the kernel counter is unreadable (not
|
|
3
4
|
* Linux / no CONFIG_SCHEDSTATS / first cycle) — attribution is evidence, and
|
|
4
5
|
* absent evidence stays absent rather than defaulting to a blame. */
|
|
@@ -48,6 +49,12 @@ export interface ReadinessReporterOptions {
|
|
|
48
49
|
publicApi: VenueReachabilityProbe;
|
|
49
50
|
/** Number of trading tools registered (a health signal). */
|
|
50
51
|
toolCount: number;
|
|
52
|
+
/** Release version from the shipped manifest (e.g. '0.1.27'); omitted from
|
|
53
|
+
* the report when unknown — never fabricated. */
|
|
54
|
+
pluginVersion?: string;
|
|
55
|
+
/** How this plugin was installed — decides which update path the dashboard
|
|
56
|
+
* offers ('npx' one-click, 'clawhub' steps, 'source' silent). */
|
|
57
|
+
installChannel?: InstallChannel;
|
|
51
58
|
/** Resolve the stop-protection snapshot at CALL time (deferred closure over
|
|
52
59
|
* the runtime — follows paper↔live flips and adapter swaps). Absent/null →
|
|
53
60
|
* the `live_stop_protection` row is omitted, never fabricated. */
|
|
@@ -67,7 +74,7 @@ export interface ReadinessReporterOptions {
|
|
|
67
74
|
* warn/fail drift is reported 'unknown' (not amber/red) so the readiness
|
|
68
75
|
* banner doesn't cry-wolf for ~5 min after every restart; a genuinely
|
|
69
76
|
* skewed clock still surfaces on cycle 2. */
|
|
70
|
-
export declare function collectReadiness(opts: Pick<ReadinessReporterOptions, 'venue' | 'publicApi' | 'toolCount' | 'resolveStopProtection'>, bootWarmup?: boolean, deps?: {
|
|
77
|
+
export declare function collectReadiness(opts: Pick<ReadinessReporterOptions, 'venue' | 'publicApi' | 'toolCount' | 'resolveStopProtection' | 'pluginVersion' | 'installChannel'>, bootWarmup?: boolean, deps?: {
|
|
71
78
|
/** Debounce memory. Omitted → a fresh state, so a lone unreachable reads
|
|
72
79
|
* `unknown`; only a caller that persists state across cycles can ever
|
|
73
80
|
* reach the warn rung. */
|
|
@@ -14,8 +14,11 @@ import { startEventLoopMonitor, sampleEventLoopDelayMs, sampleRunqueueWaitMs, }
|
|
|
14
14
|
const TAG = 'readiness';
|
|
15
15
|
const DEFAULT_INTERVAL_MS = 300_000; // 5 min — geo/clock state changes rarely.
|
|
16
16
|
const MIN_INTERVAL_MS = 60_000;
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
// The plugin's RELEASE version + install channel arrive via options (index.ts
|
|
18
|
+
// resolves them from the shipped manifest — plugin/src/plugin-version.ts).
|
|
19
|
+
// They used to be a hardcoded internal constant ('3.8.0') in a different
|
|
20
|
+
// namespace from the release versions, which left the dashboard's update
|
|
21
|
+
// banner permanently inert.
|
|
19
22
|
/** Consecutive non-pass reachability probes required before the banner goes
|
|
20
23
|
* amber. One 5-min sample is not enough evidence to send an operator hunting a
|
|
21
24
|
* network fault — the intel-health AMBER rung already debounces the same way
|
|
@@ -288,7 +291,8 @@ export async function collectReadiness(opts, bootWarmup = false, deps = {}) {
|
|
|
288
291
|
overall: deriveOverallReadiness(checks),
|
|
289
292
|
checks,
|
|
290
293
|
agent: {
|
|
291
|
-
pluginVersion:
|
|
294
|
+
...(opts.pluginVersion ? { pluginVersion: opts.pluginVersion } : {}),
|
|
295
|
+
...(opts.installChannel ? { installChannel: opts.installChannel } : {}),
|
|
292
296
|
toolCount: opts.toolCount,
|
|
293
297
|
venue: opts.venue,
|
|
294
298
|
...(credentialPublicKey ? { credentialPublicKey } : {}),
|
|
@@ -13,6 +13,14 @@ export interface DbVsExchangeContext {
|
|
|
13
13
|
* venue's snapshot can never contain. Absent → unscoped (legacy). */
|
|
14
14
|
resolveExchange?: () => 'binance' | 'hyperliquid';
|
|
15
15
|
}
|
|
16
|
+
/** Rows opened more recently than this are never orphan-closed. An entry
|
|
17
|
+
* that filled seconds ago and is "absent from the exchange" is propagation
|
|
18
|
+
* lag, not an orphan: the paper book's debounced state.json save (1s) has
|
|
19
|
+
* not landed in the other process yet, or a live fill's REST snapshot raced
|
|
20
|
+
* the journal POST. A real orphan is still harvested by the next sweep
|
|
21
|
+
* (5 min) — the grace costs nothing and removes the false-close class that
|
|
22
|
+
* hit every entry on a paying tenant's box on 2026-09-14. */
|
|
23
|
+
export declare const DEFAULT_MIN_OPEN_AGE_MS = 120000;
|
|
16
24
|
/**
|
|
17
25
|
* Close webapp `positions` rows that are status='open' but absent from the
|
|
18
26
|
* (trusted) exchange snapshot. Returns the number of synthetic closes posted.
|
|
@@ -23,6 +31,9 @@ export interface DbVsExchangeContext {
|
|
|
23
31
|
export interface DbReconcileOptions {
|
|
24
32
|
/** Provenance tag written into closeAssessment.source (default boot sweep). */
|
|
25
33
|
source?: string;
|
|
34
|
+
/** Young-row grace (ms); rows with `nowMs - openedAt < minOpenAgeMs` are
|
|
35
|
+
* skipped with a log line. Default DEFAULT_MIN_OPEN_AGE_MS; 0 disables. */
|
|
36
|
+
minOpenAgeMs?: number;
|
|
26
37
|
/** Attribution hook (issue #203): given an orphaned symbol, return a short
|
|
27
38
|
* human-readable description of what the execution engine last knew about
|
|
28
39
|
* it (e.g. the simulator's last trade). Logged with the synthetic close so
|
|
@@ -32,6 +43,8 @@ export interface DbReconcileOptions {
|
|
|
32
43
|
export declare function reconcileDbOpenVsExchange(ctx: DbVsExchangeContext, exchangeSymbols: Iterable<string>, nowMs?: number, opts?: DbReconcileOptions): Promise<number>;
|
|
33
44
|
export declare const DEFAULT_DB_RECONCILE_INTERVAL_MS = 300000;
|
|
34
45
|
export interface PeriodicDbReconcileDeps extends DbVsExchangeContext {
|
|
46
|
+
/** Young-row grace forwarded to every sweep (default DEFAULT_MIN_OPEN_AGE_MS). */
|
|
47
|
+
minOpenAgeMs?: number;
|
|
35
48
|
/** Resolve the ACTIVE adapter each tick (follows runtime reconnects). */
|
|
36
49
|
resolveAdapter: () => {
|
|
37
50
|
getPositionsOrNull(symbol?: string): Promise<Array<{
|
|
@@ -43,6 +43,14 @@ const TAG = 'reconcile-db-vs-exchange';
|
|
|
43
43
|
function canonical(symbol) {
|
|
44
44
|
return symbol.split(':')[0];
|
|
45
45
|
}
|
|
46
|
+
/** Rows opened more recently than this are never orphan-closed. An entry
|
|
47
|
+
* that filled seconds ago and is "absent from the exchange" is propagation
|
|
48
|
+
* lag, not an orphan: the paper book's debounced state.json save (1s) has
|
|
49
|
+
* not landed in the other process yet, or a live fill's REST snapshot raced
|
|
50
|
+
* the journal POST. A real orphan is still harvested by the next sweep
|
|
51
|
+
* (5 min) — the grace costs nothing and removes the false-close class that
|
|
52
|
+
* hit every entry on a paying tenant's box on 2026-09-14. */
|
|
53
|
+
export const DEFAULT_MIN_OPEN_AGE_MS = 120_000;
|
|
46
54
|
export async function reconcileDbOpenVsExchange(ctx, exchangeSymbols, nowMs = Date.now(), opts = {}) {
|
|
47
55
|
if (!ctx.decisionsClient || !ctx.userId)
|
|
48
56
|
return 0;
|
|
@@ -58,7 +66,16 @@ export async function reconcileDbOpenVsExchange(ctx, exchangeSymbols, nowMs = Da
|
|
|
58
66
|
const exchangeSet = new Set();
|
|
59
67
|
for (const s of exchangeSymbols)
|
|
60
68
|
exchangeSet.add(canonical(s));
|
|
61
|
-
const
|
|
69
|
+
const minOpenAgeMs = opts.minOpenAgeMs ?? DEFAULT_MIN_OPEN_AGE_MS;
|
|
70
|
+
const absent = resp.positions.filter((p) => !exchangeSet.has(canonical(p.symbol)));
|
|
71
|
+
const orphans = absent.filter((p) => {
|
|
72
|
+
const ageMs = nowMs - p.openedAt;
|
|
73
|
+
if (Number.isFinite(ageMs) && ageMs < minOpenAgeMs) {
|
|
74
|
+
logger.info(TAG, `${p.symbol}: opened ${Math.round(ageMs / 1000)}s ago and absent from the snapshot — within the ${Math.round(minOpenAgeMs / 1000)}s grace, not treated as an orphan (propagation lag; next sweep re-checks)`);
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
return true;
|
|
78
|
+
});
|
|
62
79
|
if (orphans.length === 0) {
|
|
63
80
|
logger.info(TAG, `DB reconcile: all ${resp.positions.length} open row(s) present on exchange`);
|
|
64
81
|
return 0;
|
|
@@ -149,7 +166,11 @@ export function startPeriodicDbReconcile(deps, intervalMs = resolveDbReconcileIn
|
|
|
149
166
|
logger.warn(TAG, 'periodic sweep skipped — positions fetch untrusted (null)');
|
|
150
167
|
return 0;
|
|
151
168
|
}
|
|
152
|
-
return await reconcileDbOpenVsExchange(deps, positions.map((p) => p.symbol), Date.now(), {
|
|
169
|
+
return await reconcileDbOpenVsExchange(deps, positions.map((p) => p.symbol), Date.now(), {
|
|
170
|
+
source: 'db_exchange_sweep_periodic',
|
|
171
|
+
describeLastExit: deps.describeLastExit,
|
|
172
|
+
minOpenAgeMs: deps.minOpenAgeMs,
|
|
173
|
+
});
|
|
153
174
|
}
|
|
154
175
|
catch (err) {
|
|
155
176
|
logger.warn(TAG, `periodic sweep failed: ${err instanceof Error ? err.message : String(err)}`);
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "reefclaw-paper-trading",
|
|
3
3
|
"name": "ReefClaw Trading",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.28",
|
|
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.
|
|
3
|
+
"version": "0.1.28",
|
|
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",
|
package/paper-adapter.js
CHANGED
|
@@ -25,17 +25,25 @@ export class PaperAdapter {
|
|
|
25
25
|
async closePosition(symbol, closeReason) {
|
|
26
26
|
return this.simulator.closePosition(symbol, closeReason);
|
|
27
27
|
}
|
|
28
|
+
// Reads pull a newer state.json first (ExchangeSimulator.refreshState —
|
|
29
|
+
// no-op without a hook). Out-of-tool readers (DB-vs-exchange sweep,
|
|
30
|
+
// stop-watcher) otherwise see a stale book in the process that did not
|
|
31
|
+
// execute the entry and post false synthetic closes / phantom stop fills.
|
|
28
32
|
async getBalance() {
|
|
33
|
+
this.simulator.refreshState();
|
|
29
34
|
return this.simulator.getBalance();
|
|
30
35
|
}
|
|
31
36
|
async getPositions(symbol) {
|
|
37
|
+
this.simulator.refreshState();
|
|
32
38
|
return this.simulator.getPositions(symbol);
|
|
33
39
|
}
|
|
34
40
|
/** Paper has no exchange fetch to fail — position state is always known. */
|
|
35
41
|
async getPositionsOrNull(symbol) {
|
|
42
|
+
this.simulator.refreshState();
|
|
36
43
|
return this.simulator.getPositions(symbol);
|
|
37
44
|
}
|
|
38
45
|
async getOpenOrders(symbol) {
|
|
46
|
+
this.simulator.refreshState();
|
|
39
47
|
return this.simulator.getOpenOrders(symbol);
|
|
40
48
|
}
|
|
41
49
|
async fetchOrder(orderId, _symbol) {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export type InstallChannel = 'npx' | 'clawhub' | 'source';
|
|
2
|
+
export interface PluginInstallFacts {
|
|
3
|
+
/** Release version from the shipped manifest (e.g. '0.1.27'); undefined
|
|
4
|
+
* when no usable manifest sits next to index.js — never fabricated. */
|
|
5
|
+
version?: string;
|
|
6
|
+
channel: InstallChannel;
|
|
7
|
+
}
|
|
8
|
+
/** The repo manifest's placeholder version. A box reporting it was not
|
|
9
|
+
* installed from a release package (no release will ever be 0.1.0 — the
|
|
10
|
+
* release line passed it long ago). */
|
|
11
|
+
export declare const UNSTAMPED_VERSION = "0.1.0";
|
|
12
|
+
export interface ResolveInstallFactsInput {
|
|
13
|
+
/** Directory holding the plugin's index.js (+ openclaw.plugin.json). */
|
|
14
|
+
pluginRoot: string;
|
|
15
|
+
/** plugin-config.json `connectorSupervisor` — the npx installer writes 'on'
|
|
16
|
+
* on every install; nothing else does. */
|
|
17
|
+
connectorSupervisor?: 'on' | 'off';
|
|
18
|
+
/** ~/.reefclaw (injectable for tests). */
|
|
19
|
+
reefclawHome?: string;
|
|
20
|
+
}
|
|
21
|
+
export declare function resolvePluginInstallFacts(input: ResolveInstallFactsInput): PluginInstallFacts;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Which ReefClaw plugin RELEASE is running, and how it was installed — the two
|
|
2
|
+
// facts the dashboard needs to (a) tell the trader an update exists and (b)
|
|
3
|
+
// offer the right update path (one click for npx installs, ClawHub steps for
|
|
4
|
+
// ClawHub installs, silence for the operator's own source/dist deploys).
|
|
5
|
+
//
|
|
6
|
+
// Version source: the `openclaw.plugin.json` that ships NEXT TO index.js in
|
|
7
|
+
// every installed layout. Both release channels stamp it with the package
|
|
8
|
+
// release version — installer/scripts/bundle-assets.mjs for `npx
|
|
9
|
+
// @reefclaw/connect`, plugin-package/scripts/assemble.mjs for ClawHub. The
|
|
10
|
+
// repo's own manifest carries the unstamped 0.1.0, which is what tells a
|
|
11
|
+
// source-tree / script-deployed box apart from a packaged install.
|
|
12
|
+
//
|
|
13
|
+
// History: the readiness report used to send a hardcoded internal constant
|
|
14
|
+
// ('3.8.0') that lived in a different namespace from the release versions
|
|
15
|
+
// ('0.1.x'), so the dashboard's update banner compared apples to oranges and
|
|
16
|
+
// never fired (memory feedback_plugin_update_banner_inert_version_namespace).
|
|
17
|
+
// The webapp still recognises that legacy value and nudges those boxes once.
|
|
18
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
19
|
+
import { join, resolve, sep } from 'node:path';
|
|
20
|
+
import { homedir } from 'node:os';
|
|
21
|
+
/** The repo manifest's placeholder version. A box reporting it was not
|
|
22
|
+
* installed from a release package (no release will ever be 0.1.0 — the
|
|
23
|
+
* release line passed it long ago). */
|
|
24
|
+
export const UNSTAMPED_VERSION = '0.1.0';
|
|
25
|
+
const RELEASE_VERSION_RE = /^\d+\.\d+\.\d+$/;
|
|
26
|
+
export function resolvePluginInstallFacts(input) {
|
|
27
|
+
let version;
|
|
28
|
+
try {
|
|
29
|
+
const raw = readFileSync(join(input.pluginRoot, 'openclaw.plugin.json'), 'utf-8');
|
|
30
|
+
const v = JSON.parse(raw).version;
|
|
31
|
+
if (typeof v === 'string' && RELEASE_VERSION_RE.test(v.trim()))
|
|
32
|
+
version = v.trim();
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// No (readable) manifest next to index.js — legacy deploy layout. Report
|
|
36
|
+
// nothing rather than a guess; the dashboard fails open (no banner).
|
|
37
|
+
}
|
|
38
|
+
return { version, channel: detectChannel(input, version) };
|
|
39
|
+
}
|
|
40
|
+
function detectChannel(input, version) {
|
|
41
|
+
// Unstamped or absent manifest → the repo's own: source tree or a dist deploy
|
|
42
|
+
// (the operator's rigs, updated by deploy scripts — never by the installer).
|
|
43
|
+
if (version === undefined || version === UNSTAMPED_VERSION)
|
|
44
|
+
return 'source';
|
|
45
|
+
// ClawHub packages carry the bootstrap skill at the package root
|
|
46
|
+
// (plugin-package/scripts/assemble.mjs); the npx layout deliberately does
|
|
47
|
+
// not. Checked FIRST: a box that once ran the npx installer and later
|
|
48
|
+
// installed from ClawHub loads the ClawHub copy.
|
|
49
|
+
if (existsSync(join(input.pluginRoot, 'skills')))
|
|
50
|
+
return 'clawhub';
|
|
51
|
+
const home = resolve(input.reefclawHome ?? join(homedir(), '.reefclaw'));
|
|
52
|
+
const root = resolve(input.pluginRoot);
|
|
53
|
+
if (input.connectorSupervisor === 'on' || root.startsWith(home + sep))
|
|
54
|
+
return 'npx';
|
|
55
|
+
// Stamped release package without an installer marker (placed by hand).
|
|
56
|
+
// The installer still updates it in place, so it gets the one-click path.
|
|
57
|
+
return 'npx';
|
|
58
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
{
|
|
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
|
+
"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."
|
|
5
|
+
}
|
|
@@ -63,6 +63,13 @@ export declare class ExchangeSimulator extends EventEmitter {
|
|
|
63
63
|
* on open and is released on close, so we must add it back here. */
|
|
64
64
|
computeEquity(): number;
|
|
65
65
|
private getQuoteCurrency;
|
|
66
|
+
private stateRefresher;
|
|
67
|
+
/** Install the "reload state.json if it changed" hook (index.ts reloadState). */
|
|
68
|
+
setStateRefresher(fn: (() => void) | null): void;
|
|
69
|
+
/** Pull a newer on-disk snapshot before an out-of-tool read. No-op when no
|
|
70
|
+
* hook is installed (tests / single-process). Never throws — a failed
|
|
71
|
+
* reload leaves the current in-memory book in place. */
|
|
72
|
+
refreshState(): void;
|
|
66
73
|
getBalance(): CcxtBalance;
|
|
67
74
|
getPositions(symbol?: string): CcxtPosition[];
|
|
68
75
|
getOpenOrders(symbol?: string): CcxtOrder[];
|
|
@@ -153,6 +153,36 @@ export class ExchangeSimulator extends EventEmitter {
|
|
|
153
153
|
getQuoteCurrency() {
|
|
154
154
|
return this.state.config?.quoteCurrency ?? 'USDT';
|
|
155
155
|
}
|
|
156
|
+
// ---- Cross-process refresh (two-process architecture) ----
|
|
157
|
+
//
|
|
158
|
+
// The agent and gateway processes each hold their own ExchangeSimulator and
|
|
159
|
+
// share state through state.json. Every paper TOOL path calls index.ts
|
|
160
|
+
// reloadState() before reading, but out-of-tool readers (the periodic
|
|
161
|
+
// DB-vs-exchange sweep, the stop-watcher's 3s poll) read the adapter
|
|
162
|
+
// directly — and a process that did NOT execute the entry then sees a
|
|
163
|
+
// stale in-memory book. On 2026-09-14 that posted a synthetic
|
|
164
|
+
// `reconciler_observed_flat` close 5–100s after EVERY entry on a paying
|
|
165
|
+
// tenant's box while the engine kept the position alive for hours. The
|
|
166
|
+
// hook lives on the simulator (not the adapter) so every PaperAdapter ever
|
|
167
|
+
// built from it — boot or any reconnect path — inherits it.
|
|
168
|
+
stateRefresher = null;
|
|
169
|
+
/** Install the "reload state.json if it changed" hook (index.ts reloadState). */
|
|
170
|
+
setStateRefresher(fn) {
|
|
171
|
+
this.stateRefresher = fn;
|
|
172
|
+
}
|
|
173
|
+
/** Pull a newer on-disk snapshot before an out-of-tool read. No-op when no
|
|
174
|
+
* hook is installed (tests / single-process). Never throws — a failed
|
|
175
|
+
* reload leaves the current in-memory book in place. */
|
|
176
|
+
refreshState() {
|
|
177
|
+
if (!this.stateRefresher)
|
|
178
|
+
return;
|
|
179
|
+
try {
|
|
180
|
+
this.stateRefresher();
|
|
181
|
+
}
|
|
182
|
+
catch (err) {
|
|
183
|
+
logger.warn(TAG, `state refresh failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
156
186
|
// ---- Read operations (for tools) ----
|
|
157
187
|
getBalance() {
|
|
158
188
|
const free = {};
|