@reefclaw/connect 0.1.39 → 0.1.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/README.md +23 -0
  2. package/assets/bridge/config.d.ts +7 -0
  3. package/assets/bridge/config.js +13 -0
  4. package/assets/bridge/gateway/agent-scope.d.ts +30 -0
  5. package/assets/bridge/gateway/agent-scope.js +67 -0
  6. package/assets/bridge/gateway/gateway-config.d.ts +18 -3
  7. package/assets/bridge/gateway/gateway-config.js +42 -4
  8. package/assets/bridge/gateway/gateway-ws-client.d.ts +24 -0
  9. package/assets/bridge/gateway/gateway-ws-client.js +87 -4
  10. package/assets/bridge/index.js +35 -30
  11. package/assets/bridge/providers/connector-update.d.ts +43 -0
  12. package/assets/bridge/providers/connector-update.js +149 -0
  13. package/assets/bridge/providers/gateway.d.ts +10 -5
  14. package/assets/bridge/providers/gateway.js +96 -79
  15. package/assets/plugin/exchange-adapter.d.ts +7 -2
  16. package/assets/plugin/index.js +12 -0
  17. package/assets/plugin/ingest/readiness-reporter.d.ts +8 -1
  18. package/assets/plugin/ingest/readiness-reporter.js +7 -3
  19. package/assets/plugin/live/stop-watcher.js +7 -1
  20. package/assets/plugin/openclaw.plugin.json +1 -1
  21. package/assets/plugin/paper-adapter.d.ts +1 -1
  22. package/assets/plugin/paper-adapter.js +2 -2
  23. package/assets/plugin/plugin-version.d.ts +21 -0
  24. package/assets/plugin/plugin-version.js +58 -0
  25. package/assets/plugin/simulator/exchange-simulator.js +5 -1
  26. package/assets/plugin/simulator/realistic-fills.d.ts +16 -0
  27. package/assets/plugin/simulator/realistic-fills.js +26 -2
  28. package/assets/plugin/simulator/types.d.ts +4 -0
  29. package/assets/shared/readiness.d.ts +5 -0
  30. package/dist/agents.js +92 -0
  31. package/dist/cli.js +16 -0
  32. package/package.json +5 -2
@@ -33,6 +33,22 @@
33
33
  // `classifyTransportLoss`.
34
34
  // - The PTY runs as the gateway's own user (`openclaw`), which is exactly the
35
35
  // user the installer must run as. Correct by construction.
36
+ //
37
+ // Hardening after the 2026-09-16 self-hoster audit (a relay message that ends
38
+ // in a PTY on the operator's machine is a capability worth fencing even with
39
+ // a constant command):
40
+ // - Per-host opt-out: REEFCLAW_ALLOW_CONNECTOR_UPDATE=0 (or plugin-config.json
41
+ // `"connectorUpdate": "off"`) makes the provider refuse before any gateway
42
+ // call (`describeUpdateDisabled`). The gateway's own switch
43
+ // `gateway.terminal.enabled=false` is honoured too (`describeOpenFailure`).
44
+ // - terminal.* needs operator.admin: the update opens a separate short-lived
45
+ // admin session and checks the granted scopes before `terminal.open`.
46
+ // - Residual trust: `@latest` resolves on the npm registry, so the trust root
47
+ // is the registry plus the publisher account (2FA), exactly as for a manual
48
+ // `npx`. Pinning an exact, offline-signed version is the planned follow-up
49
+ // (the SKILL.md OTA already carries the Ed25519 key for it).
50
+ // - The updater overwrites local modifications to the placed bridge/plugin,
51
+ // as any reinstall would. Self-hosters carrying patches: switch it off.
36
52
  import { logger } from '../logger.js';
37
53
  const TAG = 'connector-update';
38
54
  /**
@@ -85,12 +101,36 @@ export function describeOpenFailure(err) {
85
101
  + 'Update from a shell on the box instead: npx -y @reefclaw/connect@latest',
86
102
  };
87
103
  }
104
+ // Scope refusal: terminal.* requires operator.admin on OpenClaw >= 2026.7
105
+ // (verified in the 2026.9.4 method-scopes table). The steady-state bridge
106
+ // session runs on operator.read/write, so a bridge that predates the
107
+ // admin-session update (2026-09-14) is refused right here. Nothing changed
108
+ // on the box; the fix is one manual update, after which this is one click.
109
+ if (/operator\.admin|scope|forbidden|unauthori[sz]ed|not allowed|\b403\b/.test(lower)) {
110
+ return {
111
+ ok: false,
112
+ status: 'blocked',
113
+ message: 'The gateway refused to open a terminal for the connector (it needs the operator.admin scope). '
114
+ + 'One-click updates need a connector released 2026-09-14 or later — update once by pasting the manual '
115
+ + 'command to your agent or from a shell: npx -y @reefclaw/connect@latest. After that, updates are one click.',
116
+ };
117
+ }
88
118
  return {
89
119
  ok: false,
90
120
  status: 'failed',
91
121
  message: `Could not open a terminal on the box: ${raw}`,
92
122
  };
93
123
  }
124
+ /** The operator switched one-click updates off on this box. Refused before
125
+ * any gateway call is made; the copy carries the manual path. */
126
+ export function describeUpdateDisabled() {
127
+ return {
128
+ ok: false,
129
+ status: 'blocked',
130
+ message: 'One-click updates are switched off on this box (REEFCLAW_ALLOW_CONNECTOR_UPDATE=0). '
131
+ + `Update from a shell on the box instead: ${CONNECTOR_UPDATE_COMMAND}`,
132
+ };
133
+ }
94
134
  /**
95
135
  * Once the command is running, losing the transport is EXPECTED — the installer
96
136
  * restarts the gateway, which is the process hosting both the PTY and our own
@@ -210,3 +250,112 @@ export async function readTerminalText(rpc, sessionId) {
210
250
  export async function closeTerminal(rpc, sessionId) {
211
251
  await rpc('terminal.close', { sessionId }).catch(() => undefined);
212
252
  }
253
+ export function hasAdminScope(scopes) {
254
+ return Array.isArray(scopes) && scopes.includes('operator.admin');
255
+ }
256
+ export function describeMissingAdminScope(granted) {
257
+ const list = granted && granted.length > 0 ? granted.join(', ') : 'none';
258
+ return {
259
+ ok: false,
260
+ status: 'blocked',
261
+ message: `This gateway did not grant the connector the operator.admin scope it needs to open a terminal (granted: ${list}). `
262
+ + 'One-click updates are unavailable on this box — update from a shell instead: npx -y @reefclaw/connect@latest',
263
+ };
264
+ }
265
+ /** Connect a fresh admin-scoped client and wait for hello-ok. Resolves with the
266
+ * granted scopes, or a blocked/failed outcome (client already destroyed). */
267
+ export async function openAdminGatewaySession(client, timeoutMs = 20_000) {
268
+ const result = await new Promise((resolve) => {
269
+ const timer = setTimeout(() => resolve(new Error('timed out waiting for the gateway handshake')), timeoutMs);
270
+ client.on('connected', (hello) => {
271
+ clearTimeout(timer);
272
+ resolve(hello?.auth?.scopes ?? []);
273
+ });
274
+ client.on('failed', (payload) => {
275
+ clearTimeout(timer);
276
+ resolve(new Error(payload?.reason ?? 'gateway connection failed'));
277
+ });
278
+ client.connect();
279
+ });
280
+ if (result instanceof Error) {
281
+ client.destroy();
282
+ logger.warn(TAG, `admin session failed: ${result.message}`);
283
+ return {
284
+ ok: false,
285
+ outcome: { ok: false, status: 'failed', message: `Could not open an admin session on the gateway: ${result.message}` },
286
+ };
287
+ }
288
+ if (!hasAdminScope(result)) {
289
+ client.destroy();
290
+ logger.warn(TAG, `admin session granted [${result.join(', ')}] — operator.admin missing, update refused`);
291
+ return { ok: false, outcome: describeMissingAdminScope(result) };
292
+ }
293
+ logger.info(TAG, `admin session open (scopes: ${result.join(', ')})`);
294
+ return { ok: true, scopes: result };
295
+ }
296
+ /**
297
+ * Poll the PTY and emit progress until the command finishes or the gateway
298
+ * restart takes the session down. Returns the final outcome. A single failed
299
+ * read is not proof the gateway went away (transient RPC error under install
300
+ * load) — two in a row are required before declaring the restart.
301
+ */
302
+ export async function runConnectorUpdate(rpc, sessionId, emit, opts = {}) {
303
+ const pollMs = opts.pollMs ?? 1500;
304
+ // Generous: an npm install on a small VPS is slow (240 × 1.5 s = 6 min).
305
+ const maxPolls = opts.maxPolls ?? 240;
306
+ const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
307
+ let lastEmitted = '';
308
+ let consecutiveDeadReads = 0;
309
+ for (let i = 0; i < maxPolls; i++) {
310
+ await sleep(pollMs);
311
+ const screen = await readTerminalText(rpc, sessionId);
312
+ if (screen !== null)
313
+ consecutiveDeadReads = 0;
314
+ else if (++consecutiveDeadReads < 2)
315
+ continue;
316
+ if (screen === null) {
317
+ // Session (or the whole gateway) is gone. After a successful start this
318
+ // is the restart landing, not a failure.
319
+ const outcome = {
320
+ ok: true,
321
+ status: classifyTransportLoss(true),
322
+ sessionId,
323
+ output: lastEmitted,
324
+ message: 'The agent is restarting to load the new version. The dashboard reconnects on its own.',
325
+ };
326
+ emit(outcome);
327
+ return outcome;
328
+ }
329
+ if (screen !== lastEmitted) {
330
+ lastEmitted = screen;
331
+ emit({ ok: true, status: 'started', sessionId, output: screen, message: 'Updating…' });
332
+ }
333
+ if (isCredibleCompletion(screen)) {
334
+ const { exitCode } = parseDoneSentinel(screen);
335
+ const ok = exitCode === 0;
336
+ const outcome = {
337
+ ok,
338
+ status: 'completed',
339
+ sessionId,
340
+ exitCode,
341
+ output: screen,
342
+ message: ok
343
+ ? 'Connector updated. The agent restarts to load it.'
344
+ : `The updater exited with code ${exitCode}. The connector was left as it was.`,
345
+ };
346
+ emit(outcome);
347
+ await closeTerminal(rpc, sessionId);
348
+ return outcome;
349
+ }
350
+ }
351
+ const outcome = {
352
+ ok: false,
353
+ status: 'failed',
354
+ sessionId,
355
+ output: lastEmitted,
356
+ message: 'The update did not finish in time. Check the box directly before retrying.',
357
+ };
358
+ emit(outcome);
359
+ await closeTerminal(rpc, sessionId);
360
+ return outcome;
361
+ }
@@ -40,6 +40,11 @@ export declare class GatewayProvider implements OpenClawProvider {
40
40
  * Surfaced in agent_state so the dashboard header can show the real agent +
41
41
  * model instead of a hardcoded placeholder. Empty until the gateway answers. */
42
42
  private agentIdentity;
43
+ /** Foreign agent ids whose runs we have already logged as ignored (once each). */
44
+ private readonly ignoredForeignAgents;
45
+ /** The OpenClaw agent this bridge serves: chat goes to it, and only its runs
46
+ * reach the dashboard (gateway/agent-scope.ts). */
47
+ private agentId;
43
48
  /** Emit the one-time rollout probe (gateway response shape) only on the first attempt. */
44
49
  private loggedAgentIdentityProbe;
45
50
  /** Last IDENTITY.md read (epoch ms) — TTL gate for the display-name re-read. */
@@ -265,11 +270,6 @@ export declare class GatewayProvider implements OpenClawProvider {
265
270
  updateConnector(args: {
266
271
  acknowledgeOpenPositions?: boolean;
267
272
  }): 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
273
  private emitConnectorUpdate;
274
274
  /** Operator-only. Approval/balance status of the provisioned HL wallet. */
275
275
  getHlAgentWalletStatus(): Promise<HlAgentWalletStatusOutcome>;
@@ -306,6 +306,11 @@ export declare class GatewayProvider implements OpenClawProvider {
306
306
  * 2026-07-29), we read the scopes the gateway ACTUALLY granted and relax
307
307
  * only when it demonstrably withheld the one we need. On the supported
308
308
  * range the scope survives and nothing is written. */
309
+ private deviceIdentityRelaxationApplied;
310
+ /** Handshake rejected outright for missing device identity (see
311
+ * isDeviceIdentityRejection). Same escalation as the withheld-write-scope
312
+ * case, keyed on the rejection instead of on hello-ok scopes. */
313
+ private onHandshakeRejectedForDeviceIdentity;
309
314
  private checkGrantedScopes;
310
315
  private onWsConnected;
311
316
  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,8 @@ 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, describeUpdateDisabled, } from './connector-update.js';
23
+ import { DEFAULT_AGENT_ID, isForeignAgentEvent, resolveAgentEventOwner } from '../gateway/agent-scope.js';
23
24
  const TAG = 'gateway';
24
25
  // ---- Day-start NAV persistence ----
25
26
  // Persists sessionStartNav (the UTC-day P&L anchor) per date so Day P&L
@@ -159,6 +160,13 @@ export class GatewayProvider {
159
160
  * Surfaced in agent_state so the dashboard header can show the real agent +
160
161
  * model instead of a hardcoded placeholder. Empty until the gateway answers. */
161
162
  agentIdentity = {};
163
+ /** Foreign agent ids whose runs we have already logged as ignored (once each). */
164
+ ignoredForeignAgents = new Set();
165
+ /** The OpenClaw agent this bridge serves: chat goes to it, and only its runs
166
+ * reach the dashboard (gateway/agent-scope.ts). */
167
+ agentId() {
168
+ return this.config.agentId ?? DEFAULT_AGENT_ID;
169
+ }
162
170
  /** Emit the one-time rollout probe (gateway response shape) only on the first attempt. */
163
171
  loggedAgentIdentityProbe = false;
164
172
  /** Last IDENTITY.md read (epoch ms) — TTL gate for the display-name re-read. */
@@ -669,7 +677,7 @@ export class GatewayProvider {
669
677
  }
670
678
  try {
671
679
  const idempotencyKey = `chat-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
672
- await ws.sendRpc('agent', { message: content, idempotencyKey, agentId: 'main' });
680
+ await ws.sendRpc('agent', { message: content, idempotencyKey, agentId: this.agentId() });
673
681
  return { received: true };
674
682
  }
675
683
  catch (err) {
@@ -749,87 +757,51 @@ export class GatewayProvider {
749
757
  * `restarting` and the dashboard waits for the reconnect.
750
758
  */
751
759
  async updateConnector(args) {
752
- const ws = this.wsClient;
753
- if (!ws) {
760
+ // Per-host opt-out FIRST: a box whose operator switched this off must
761
+ // never open a PTY, connected or not (REEFCLAW_ALLOW_CONNECTOR_UPDATE=0 /
762
+ // plugin-config.json connectorUpdate:'off').
763
+ if (this.config.allowConnectorUpdate === false) {
764
+ logger.info(TAG, 'connector.update refused: one-click updates are switched off on this box');
765
+ return describeUpdateDisabled();
766
+ }
767
+ if (!this.wsClient) {
754
768
  return { ok: false, status: 'failed', message: 'Not connected to the OpenClaw gateway.' };
755
769
  }
756
- const rpc = (method, params) => ws.sendRpc(method, params);
757
770
  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)
771
+ const guardArgs = { acknowledgeOpenPositions: args.acknowledgeOpenPositions, openPositionCount };
772
+ // Position guard FIRST — never open an admin session for a request that is
773
+ // going to be refused anyway.
774
+ const blocked = checkPositionGuard(guardArgs);
775
+ if (blocked)
776
+ return blocked;
777
+ // terminal.* is operator.admin-only (OpenClaw method-scopes, since 2026.7).
778
+ // The long-lived session runs on operator.read/write by design, so open a
779
+ // SEPARATE short-lived admin session for this update. Reconnects are
780
+ // effectively disabled: the gateway restart is supposed to kill it.
781
+ const admin = new GatewayWsClient({
782
+ gatewayUrl: this.config.gatewayUrl,
783
+ gatewayToken: this.config.gatewayToken,
784
+ requestAdminScope: true,
785
+ // The identity the long-lived session was admitted under; no ladder here.
786
+ identities: [this.wsClient.getClientId()],
787
+ requireScope: 'operator.admin',
788
+ }, { baseDelayMs: 60_000, maxDelayMs: 60_000, maxFastAttempts: 0, jitterFactor: 0, slowRetryMs: 3_600_000 });
789
+ const session = await openAdminGatewaySession(admin);
790
+ if (!session.ok)
791
+ return session.outcome;
792
+ const rpc = (method, params) => admin.sendRpc(method, params);
793
+ const started = await startConnectorUpdate(rpc, guardArgs);
794
+ if (started.status !== 'started' || !started.sessionId) {
795
+ admin.destroy();
763
796
  return started;
797
+ }
764
798
  // Stream progress in the background; the RPC returns as soon as the command
765
799
  // 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.',
800
+ // The admin session is torn down when the loop ends, whichever way.
801
+ void runConnectorUpdate(rpc, started.sessionId, (outcome) => this.emitConnectorUpdate(outcome)).finally(() => {
802
+ admin.destroy();
831
803
  });
832
- await closeTerminal(rpc, sessionId);
804
+ return started;
833
805
  }
834
806
  emitConnectorUpdate(payload) {
835
807
  this.fire('connectorUpdate', payload);
@@ -1283,6 +1255,12 @@ export class GatewayProvider {
1283
1255
  this.wsClient.on('disconnected', ({ code, reason }) => {
1284
1256
  logger.warn(TAG, `WS disconnected: ${code} ${reason}`);
1285
1257
  this.poller?.pause();
1258
+ // OpenClaw >= 2026.9: an unrelaxed gateway rejects a control-UI client
1259
+ // without device identity AT THE HANDSHAKE, so checkGrantedScopes (which
1260
+ // needs a completed handshake) never runs and the bridge would reconnect
1261
+ // forever with no diagnosis. The rejection itself is the proven need.
1262
+ if (isDeviceIdentityRejection(code, reason))
1263
+ this.onHandshakeRejectedForDeviceIdentity(reason);
1286
1264
  });
1287
1265
  this.wsClient.on('reconnecting', ({ attempt, delayMs }) => {
1288
1266
  logger.info(TAG, `WS reconnecting: attempt ${attempt}, delay ${Math.round(delayMs)}ms`);
@@ -1303,6 +1281,31 @@ export class GatewayProvider {
1303
1281
  * 2026-07-29), we read the scopes the gateway ACTUALLY granted and relax
1304
1282
  * only when it demonstrably withheld the one we need. On the supported
1305
1283
  * range the scope survives and nothing is written. */
1284
+ deviceIdentityRelaxationApplied = false;
1285
+ /** Handshake rejected outright for missing device identity (see
1286
+ * isDeviceIdentityRejection). Same escalation as the withheld-write-scope
1287
+ * case, keyed on the rejection instead of on hello-ok scopes. */
1288
+ onHandshakeRejectedForDeviceIdentity(reason) {
1289
+ if (this.deviceIdentityRelaxationApplied)
1290
+ return;
1291
+ this.deviceIdentityRelaxationApplied = true;
1292
+ const tried = this.wsClient?.getClientId() ?? 'unknown';
1293
+ logger.error(TAG, `Gateway REJECTED the connector handshake under every client identity (last: ${tried}): ${reason}. ` +
1294
+ `OpenClaw >= 2026.9 admits only a local-backend client (gateway-client) or a device-paired one — ` +
1295
+ `the retired gateway.controlUi relaxation no longer applies — and this gateway did neither. The ` +
1296
+ `dashboard cannot reach this agent (no tools, no state, no updates) until the handshake completes.`);
1297
+ // Last resort for OLDER builds only: on 2026.9+ the flags are retired and
1298
+ // ignored (a config migration deletes them), so this is best-effort and
1299
+ // says so.
1300
+ const changed = relaxGatewayDeviceAuth(`the gateway rejected the connector handshake (${reason})`);
1301
+ if (changed) {
1302
+ logger.warn(TAG, 'Wrote the legacy gateway.controlUi relaxation flags in case this is an older OpenClaw build — ' +
1303
+ 'RESTART the gateway to find out. On OpenClaw >= 2026.9 they are ignored: check the gateway ' +
1304
+ 'auth config / device pairing instead.');
1305
+ }
1306
+ this.agentMode = 'ERROR';
1307
+ this.emitAgentState();
1308
+ }
1306
1309
  checkGrantedScopes(helloOk) {
1307
1310
  const scopes = helloOk.auth?.scopes;
1308
1311
  if (!handshakeLacksWriteScope(scopes))
@@ -1378,6 +1381,19 @@ export class GatewayProvider {
1378
1381
  onAgentEvent(payload) {
1379
1382
  if (!this.started || !this.eventParser)
1380
1383
  return;
1384
+ // Only OUR agent's runs belong in the dashboard. The gateway broadcasts
1385
+ // every agent's events to every operator connection, so on a shared
1386
+ // gateway a neighbour's cron briefing used to stream into the chat and
1387
+ // across the relay (2026-09-16 audit, item 1). See gateway/agent-scope.ts.
1388
+ if (isForeignAgentEvent(payload, this.agentId())) {
1389
+ const owner = resolveAgentEventOwner(payload) ?? '?';
1390
+ if (!this.ignoredForeignAgents.has(owner)) {
1391
+ this.ignoredForeignAgents.add(owner);
1392
+ logger.warn(TAG, `Ignoring run events from agent '${owner}' — this bridge serves '${this.agentId()}' ` +
1393
+ '(the gateway default agent unless overridden; set OPENCLAW_AGENT_ID / --agent-id if that is the wrong agent)');
1394
+ }
1395
+ return;
1396
+ }
1381
1397
  // Turn lifecycle WITH the session key — the heartbeat flight recorder
1382
1398
  // keys off `agent:main:cron:…` to scan the beat's transcript right after
1383
1399
  // it ends. Fired before parsing so it never depends on parser state.
@@ -2357,7 +2373,8 @@ export class GatewayProvider {
2357
2373
  (Array.isArray(root.data) && root.data) ||
2358
2374
  [root];
2359
2375
  const rows = rowsRaw.filter((r) => !!r && typeof r === 'object');
2360
- const row = rows.find((r) => r.agentId === 'main') ?? rows[0];
2376
+ const own = this.agentId();
2377
+ const row = rows.find((r) => r.agentId === own) ?? rows[0];
2361
2378
  const runtime = row ? (asObj(row.agentRuntime) ?? asObj(row.runtime)) : undefined;
2362
2379
  const defaults = asObj(root.defaults);
2363
2380
  return flatModel(row?.model) ?? flatModel(runtime?.model) ?? flatModel(defaults?.model) ?? flatModel(root.model);
@@ -3116,7 +3133,7 @@ export class GatewayProvider {
3116
3133
  ].join('\n');
3117
3134
  try {
3118
3135
  const idempotencyKey = `mission-${mission.missionId}-${Date.now()}`;
3119
- await ws.sendRpc('agent', { message, idempotencyKey, agentId: 'main' });
3136
+ await ws.sendRpc('agent', { message, idempotencyKey, agentId: this.agentId() });
3120
3137
  logger.info(TAG, `Presented mission ${mission.missionId} to agent ($${absoluteSize.toFixed(2)}, ${quantity.toFixed(6)} units)`);
3121
3138
  }
3122
3139
  catch (err) {
@@ -48,8 +48,13 @@ export interface IExchangeAdapter {
48
48
  * @param closeReason Tags the resulting Trade record with who initiated the close.
49
49
  * Paper mode persists it on the closing Trade.metadata.closeReason so the agent
50
50
  * can see on its next heartbeat that e.g. a stop_watcher auto-closed the trade.
51
- * Live mode currently ignores this (metadata not tracked in live path). */
52
- closePosition(symbol: string, closeReason?: CloseReason): Promise<CcxtOrder>;
51
+ * Live mode currently ignores this (metadata not tracked in live path).
52
+ * @param referencePrice Paper-only: the price the closing DECISION was made
53
+ * on (a stop's breach mark, a target level). The simulator anchors the
54
+ * fill to it instead of whatever tick/book it has cached: a stop that
55
+ * filled off an entry-time cache booked a -1.04R loss as -0.02R
56
+ * (2026-09-16 audit). Live venues fill at market and ignore it. */
57
+ closePosition(symbol: string, closeReason?: CloseReason, referencePrice?: number): Promise<CcxtOrder>;
53
58
  getBalance(): Promise<CcxtBalance>;
54
59
  getPositions(symbol?: string): Promise<CcxtPosition[]>;
55
60
  /**
@@ -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
- /** 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 } : {}),
@@ -212,7 +212,13 @@ export class PositionWatcher extends EventEmitter {
212
212
  throw new Error(`Wave 9 ownership resolution failed before stop close submission: ${ownershipError}; ` +
213
213
  'IMMEDIATE MANUAL INTERVENTION REQUIRED');
214
214
  }
215
- closeOrder = await this.adapter.closePosition(trustedPosition.symbol, 'stop_watcher');
215
+ // Fill at the mark the breach decision was made on, not at whatever the
216
+ // paper engine has cached (the take-profit leg already passes its level
217
+ // the same way). Live adapters fill at market and ignore the reference.
218
+ const stopFillPrice = Number.isFinite(trustedPosition.markPrice) && trustedPosition.markPrice > 0
219
+ ? trustedPosition.markPrice
220
+ : undefined;
221
+ closeOrder = await this.adapter.closePosition(trustedPosition.symbol, 'stop_watcher', stopFillPrice);
216
222
  if (!candidateId || !this.wave9CloseLifecycle)
217
223
  return 'closed';
218
224
  const outcome = await this.wave9CloseLifecycle.settleAfterClose(candidateId, trustedPosition.symbol);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "reefclaw-paper-trading",
3
3
  "name": "ReefClaw Trading",
4
- "version": "0.1.27",
4
+ "version": "0.1.29",
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": {
@@ -13,7 +13,7 @@ export declare class PaperAdapter implements IExchangeAdapter {
13
13
  createOrder(symbol: string, side: 'buy' | 'sell', type: 'market' | 'limit', amount: number, price?: number, metadata?: PositionMetadata, _options?: OrderOptions): Promise<CcxtOrder>;
14
14
  cancelOrder(orderId: string, _symbol?: string): Promise<CcxtOrder>;
15
15
  cancelAllOrders(symbol?: string): Promise<CcxtOrder[]>;
16
- closePosition(symbol: string, closeReason?: CloseReason): Promise<CcxtOrder>;
16
+ closePosition(symbol: string, closeReason?: CloseReason, referencePrice?: number): Promise<CcxtOrder>;
17
17
  getBalance(): Promise<CcxtBalance>;
18
18
  getPositions(symbol?: string): Promise<CcxtPosition[]>;
19
19
  /** Paper has no exchange fetch to fail — position state is always known. */
@@ -22,8 +22,8 @@ export class PaperAdapter {
22
22
  async cancelAllOrders(symbol) {
23
23
  return this.simulator.cancelAllOrders(symbol);
24
24
  }
25
- async closePosition(symbol, closeReason) {
26
- return this.simulator.closePosition(symbol, closeReason);
25
+ async closePosition(symbol, closeReason, referencePrice) {
26
+ return this.simulator.closePosition(symbol, closeReason, referencePrice);
27
27
  }
28
28
  // Reads pull a newer state.json first (ExchangeSimulator.refreshState —
29
29
  // no-op without a hook). Out-of-tool readers (DB-vs-exchange sweep,
@@ -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;