@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 CHANGED
@@ -6,3 +6,4 @@
6
6
  !scripts/**
7
7
  !README.md
8
8
  !.gitignore
9
+ !release-notes.json
@@ -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 CLIENT_ID = 'openclaw-tui';
31
+ export const DEFAULT_CLIENT_IDENTITIES = ['gateway-client', 'openclaw-tui'];
32
32
  const CLIENT_VERSION = '0.1.0';
33
+ /** The gateway's wording when a control-UI client has no device identity. */
34
+ export function isDeviceIdentityRejectionMessage(message) {
35
+ return typeof message === 'string' && /device identity/i.test(message);
36
+ }
33
37
  // Mirrors the relay connector's proven shape (see connector.ts DEFAULT_RECONNECT):
34
38
  // a bounded fast-backoff burst, then a slow retry that NEVER gives up.
35
39
  //
@@ -76,8 +80,20 @@ export class GatewayWsClient {
76
80
  gatewayUrl;
77
81
  gatewayToken;
78
82
  requestAdminScope;
83
+ /** Identities to try, in order (see the client-identity note above). */
84
+ identities;
85
+ identityIndex = 0;
86
+ /** Scope the handshake must grant, else the next identity is tried. */
87
+ requireScope;
88
+ /** Set when the current socket is being closed ON PURPOSE to retry the
89
+ * handshake under the next identity: the close handler then reconnects
90
+ * immediately (no backoff, no 'disconnected' event). */
91
+ identitySwitchPending = false;
79
92
  constructor(config, reconnect) {
80
93
  this.requestAdminScope = config.requestAdminScope === true;
94
+ this.identities =
95
+ config.identities && config.identities.length > 0 ? [...config.identities] : [...DEFAULT_CLIENT_IDENTITIES];
96
+ this.requireScope = config.requireScope === undefined ? 'operator.write' : config.requireScope;
81
97
  // Convert to WebSocket URL: http→ws, https→wss. A bare host:port defaults
82
98
  // by destination: loopback → ws:// (the normal localhost gateway), anything
83
99
  // else → wss:// — a remote default must never silently downgrade to
@@ -126,9 +142,22 @@ export class GatewayWsClient {
126
142
  logger.info(TAG, `WebSocket closed: ${code} ${reasonStr}`);
127
143
  this.ws = null;
128
144
  this.clearStaleTimer();
145
+ if (this.identitySwitchPending && !this.destroyed) {
146
+ // Closed on purpose to retry the handshake under the next identity:
147
+ // no 'disconnected' event, no backoff.
148
+ this.completeIdentitySwitch();
149
+ return;
150
+ }
129
151
  this.rejectAllPending('Connection closed');
130
152
  if (this.destroyed)
131
153
  return;
154
+ // A device-identity rejection that arrived only as a close frame (no
155
+ // error response): still worth the next identity.
156
+ if (code === 1008 && isDeviceIdentityRejectionMessage(reasonStr) && this.hasNextIdentity()) {
157
+ this.identitySwitchPending = true;
158
+ this.completeIdentitySwitch();
159
+ return;
160
+ }
132
161
  const wasConnected = this.state === 'connected' || this.state === 'handshaking';
133
162
  if (wasConnected) {
134
163
  this.emit('disconnected', { code, reason: reasonStr });
@@ -202,6 +231,40 @@ export class GatewayWsClient {
202
231
  getHelloOk() {
203
232
  return this.lastHelloOk;
204
233
  }
234
+ /** The client identity currently in use (the one the gateway admitted, once connected). */
235
+ getClientId() {
236
+ return this.identities[this.identityIndex];
237
+ }
238
+ hasNextIdentity() {
239
+ return this.identityIndex + 1 < this.identities.length;
240
+ }
241
+ /** Close the current socket and retry the handshake under the next identity. */
242
+ switchIdentity(why) {
243
+ const from = this.getClientId();
244
+ const to = this.identities[this.identityIndex + 1];
245
+ logger.warn(TAG, `Gateway would not take client id ${from} (${why}) — retrying the handshake as ${to}`);
246
+ // No rejectAllPending here: the handshake's pending entry is already gone
247
+ // (handleResponse deletes it before invoking resolve/reject, and this can
248
+ // run from inside that reject), so the close handler does the rest.
249
+ this.identitySwitchPending = true;
250
+ const ws = this.ws;
251
+ if (ws && ws.readyState === WebSocket.OPEN) {
252
+ ws.close(4000, 'identity switch');
253
+ }
254
+ else {
255
+ this.completeIdentitySwitch();
256
+ }
257
+ }
258
+ completeIdentitySwitch() {
259
+ this.identitySwitchPending = false;
260
+ this.identityIndex++;
261
+ this.attempt = 0;
262
+ this.ws = null;
263
+ // connect() refuses to run while 'handshaking' — the state the rejected
264
+ // attempt left behind.
265
+ this.setState('disconnected');
266
+ this.connect();
267
+ }
205
268
  /** Get stored device token (for future connects) */
206
269
  getDeviceToken() {
207
270
  return this.deviceToken;
@@ -321,7 +384,7 @@ export class GatewayWsClient {
321
384
  minProtocol: MIN_PROTOCOL_VERSION,
322
385
  maxProtocol: MAX_PROTOCOL_VERSION,
323
386
  client: {
324
- id: CLIENT_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.ws?.close(4000, 'Handshake rejected');
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
- logger.info(TAG, `Connected to gateway (protocol=${payload.protocol})`);
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, readTerminalText, closeTerminal, classifyTransportLoss, isCredibleCompletion, parseDoneSentinel, } from './connector-update.js';
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
- const ws = this.wsClient;
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 started = await startConnectorUpdate(rpc, {
759
- acknowledgeOpenPositions: args.acknowledgeOpenPositions,
760
- openPositionCount,
761
- });
762
- if (started.status !== 'started' || !started.sessionId)
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
- void this.streamConnectorUpdate(rpc, started.sessionId);
767
- return started;
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
- await closeTerminal(rpc, sessionId);
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
- /** Best-effort display fact; kept in sync with the register() banner in index.ts. */
18
- const PLUGIN_VERSION = '3.8.0';
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: PLUGIN_VERSION,
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 orphans = resp.positions.filter((p) => !exchangeSet.has(canonical(p.symbol)));
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(), { source: 'db_exchange_sweep_periodic', describeLastExit: deps.describeLastExit });
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)}`);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "reefclaw-paper-trading",
3
3
  "name": "ReefClaw Trading",
4
- "version": "0.1.26",
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.26",
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 = {};