@reefclaw/connect 0.1.39 → 0.1.40
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/assets/bridge/config.d.ts +7 -0
- package/assets/bridge/config.js +13 -0
- package/assets/bridge/gateway/gateway-ws-client.d.ts +23 -0
- package/assets/bridge/gateway/gateway-ws-client.js +84 -4
- package/assets/bridge/providers/connector-update.d.ts +40 -0
- package/assets/bridge/providers/connector-update.js +123 -0
- package/assets/bridge/providers/gateway.d.ts +5 -5
- package/assets/bridge/providers/gateway.js +64 -76
- package/assets/plugin/index.js +12 -0
- package/assets/plugin/ingest/readiness-reporter.d.ts +8 -1
- package/assets/plugin/ingest/readiness-reporter.js +7 -3
- package/assets/plugin/openclaw.plugin.json +1 -1
- package/assets/plugin/plugin-version.d.ts +21 -0
- package/assets/plugin/plugin-version.js +58 -0
- package/assets/shared/readiness.d.ts +5 -0
- package/package.json +1 -1
|
@@ -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/assets/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/assets/plugin/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';
|
|
@@ -2938,6 +2939,15 @@ const paperTradingPlugin = {
|
|
|
2938
2939
|
catch (err) {
|
|
2939
2940
|
logger.warn(TAG, `credential transport key setup failed (plaintext fallback stays available): ${formatError(err)}`);
|
|
2940
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}`);
|
|
2941
2951
|
// Runs here once (guarded by the pluginInitialised early-return → once per
|
|
2942
2952
|
// process) + on an unref'd interval inside the reporter.
|
|
2943
2953
|
startReadinessReporter({
|
|
@@ -2946,6 +2956,8 @@ const paperTradingPlugin = {
|
|
|
2946
2956
|
venue,
|
|
2947
2957
|
publicApi: hlPublicApi ?? binanceApi,
|
|
2948
2958
|
toolCount: toolNames.length,
|
|
2959
|
+
pluginVersion: installFacts.version,
|
|
2960
|
+
installChannel: installFacts.channel,
|
|
2949
2961
|
// live_stop_protection (E2E audit #3): what would stop a losing live
|
|
2950
2962
|
// position. Deferred closure over the runtime so paper↔live flips and
|
|
2951
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 } : {}),
|
|
@@ -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": {
|
|
@@ -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
|
+
}
|
|
@@ -48,7 +48,12 @@ export interface ReadinessReport {
|
|
|
48
48
|
checks: ReadinessCheck[];
|
|
49
49
|
/** Best-effort agent facts for display. */
|
|
50
50
|
agent?: {
|
|
51
|
+
/** Release version from the shipped manifest ('0.1.27'). Legacy plugins
|
|
52
|
+
* sent an internal '3.x' constant — the webapp treats that as "behind". */
|
|
51
53
|
pluginVersion?: string;
|
|
54
|
+
/** 'npx' | 'clawhub' | 'source' — how the plugin was installed. Kept
|
|
55
|
+
* `string` so an older webapp renders reports from a newer plugin. */
|
|
56
|
+
installChannel?: string;
|
|
52
57
|
toolCount?: number;
|
|
53
58
|
/** Trading venue this agent executes on ('binance' | 'hyperliquid').
|
|
54
59
|
* Kept `string` so an older webapp renders reports from a newer plugin.
|