@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.
- package/README.md +23 -0
- package/assets/bridge/config.d.ts +7 -0
- package/assets/bridge/config.js +13 -0
- package/assets/bridge/gateway/agent-scope.d.ts +30 -0
- package/assets/bridge/gateway/agent-scope.js +67 -0
- package/assets/bridge/gateway/gateway-config.d.ts +18 -3
- package/assets/bridge/gateway/gateway-config.js +42 -4
- package/assets/bridge/gateway/gateway-ws-client.d.ts +24 -0
- package/assets/bridge/gateway/gateway-ws-client.js +87 -4
- package/assets/bridge/index.js +35 -30
- package/assets/bridge/providers/connector-update.d.ts +43 -0
- package/assets/bridge/providers/connector-update.js +149 -0
- package/assets/bridge/providers/gateway.d.ts +10 -5
- package/assets/bridge/providers/gateway.js +96 -79
- package/assets/plugin/exchange-adapter.d.ts +7 -2
- package/assets/plugin/index.js +12 -0
- package/assets/plugin/ingest/readiness-reporter.d.ts +8 -1
- package/assets/plugin/ingest/readiness-reporter.js +7 -3
- package/assets/plugin/live/stop-watcher.js +7 -1
- package/assets/plugin/openclaw.plugin.json +1 -1
- package/assets/plugin/paper-adapter.d.ts +1 -1
- package/assets/plugin/paper-adapter.js +2 -2
- package/assets/plugin/plugin-version.d.ts +21 -0
- package/assets/plugin/plugin-version.js +58 -0
- package/assets/plugin/simulator/exchange-simulator.js +5 -1
- package/assets/plugin/simulator/realistic-fills.d.ts +16 -0
- package/assets/plugin/simulator/realistic-fills.js +26 -2
- package/assets/plugin/simulator/types.d.ts +4 -0
- package/assets/shared/readiness.d.ts +5 -0
- package/dist/agents.js +92 -0
- package/dist/cli.js +16 -0
- 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,
|
|
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:
|
|
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
|
-
|
|
753
|
-
|
|
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
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
if (
|
|
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
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
/**
|
|
770
|
-
* Poll the PTY and emit progress until the command finishes or the gateway
|
|
771
|
-
* restart takes the session (and us) down.
|
|
772
|
-
*/
|
|
773
|
-
async streamConnectorUpdate(rpc, sessionId) {
|
|
774
|
-
const POLL_MS = 1500;
|
|
775
|
-
// Generous: an npm install on a small VPS is slow. The gateway restart
|
|
776
|
-
// normally ends this loop long before the cap.
|
|
777
|
-
const MAX_POLLS = 240;
|
|
778
|
-
let lastEmitted = '';
|
|
779
|
-
// A single failed read is not proof the gateway went away — it could be a
|
|
780
|
-
// transient RPC error under install load. Require two in a row before
|
|
781
|
-
// declaring the restart, so we do not flip the dashboard into "restarting"
|
|
782
|
-
// while the update is in fact still running.
|
|
783
|
-
let consecutiveDeadReads = 0;
|
|
784
|
-
for (let i = 0; i < MAX_POLLS; i++) {
|
|
785
|
-
await new Promise((r) => setTimeout(r, POLL_MS));
|
|
786
|
-
const screen = await readTerminalText(rpc, sessionId);
|
|
787
|
-
if (screen !== null)
|
|
788
|
-
consecutiveDeadReads = 0;
|
|
789
|
-
else if (++consecutiveDeadReads < 2)
|
|
790
|
-
continue;
|
|
791
|
-
if (screen === null) {
|
|
792
|
-
// Session (or the whole gateway) is gone. After a successful start this
|
|
793
|
-
// is the restart landing, not a failure.
|
|
794
|
-
const status = classifyTransportLoss(true);
|
|
795
|
-
this.emitConnectorUpdate({
|
|
796
|
-
ok: true,
|
|
797
|
-
status,
|
|
798
|
-
sessionId,
|
|
799
|
-
output: lastEmitted,
|
|
800
|
-
message: 'The agent is restarting to load the new version. The dashboard reconnects on its own.',
|
|
801
|
-
});
|
|
802
|
-
return;
|
|
803
|
-
}
|
|
804
|
-
if (screen !== lastEmitted) {
|
|
805
|
-
lastEmitted = screen;
|
|
806
|
-
this.emitConnectorUpdate({ ok: true, status: 'started', sessionId, output: screen, message: 'Updating…' });
|
|
807
|
-
}
|
|
808
|
-
if (isCredibleCompletion(screen)) {
|
|
809
|
-
const { exitCode } = parseDoneSentinel(screen);
|
|
810
|
-
const ok = exitCode === 0;
|
|
811
|
-
this.emitConnectorUpdate({
|
|
812
|
-
ok,
|
|
813
|
-
status: 'completed',
|
|
814
|
-
sessionId,
|
|
815
|
-
exitCode,
|
|
816
|
-
output: screen,
|
|
817
|
-
message: ok
|
|
818
|
-
? 'Connector updated. The agent restarts to load it.'
|
|
819
|
-
: `The updater exited with code ${exitCode}. The connector was left as it was.`,
|
|
820
|
-
});
|
|
821
|
-
await closeTerminal(rpc, sessionId);
|
|
822
|
-
return;
|
|
823
|
-
}
|
|
824
|
-
}
|
|
825
|
-
this.emitConnectorUpdate({
|
|
826
|
-
ok: false,
|
|
827
|
-
status: 'failed',
|
|
828
|
-
sessionId,
|
|
829
|
-
output: lastEmitted,
|
|
830
|
-
message: 'The update did not finish in time. Check the box directly before retrying.',
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
/**
|
package/assets/plugin/index.js
CHANGED
|
@@ -38,6 +38,7 @@ import { reconcileDbOpenVsExchange, startPeriodicDbReconcile } from './ingest/re
|
|
|
38
38
|
import { onStopWatcherClose } from './ingest/position-auto-capture.js';
|
|
39
39
|
import { ReentryTracker } from './portfolio/reentry-tracker.js';
|
|
40
40
|
import { startReadinessReporter } from './ingest/readiness-reporter.js';
|
|
41
|
+
import { resolvePluginInstallFacts } from './plugin-version.js';
|
|
41
42
|
import { IntelMicrostructureAssembler } from './live/microstructure-assembler.js';
|
|
42
43
|
import { recordPositionReviewsTool } from './tools/record-position-reviews.js';
|
|
43
44
|
import { getMyRecentReviewsTool } from './tools/get-my-recent-reviews.js';
|
|
@@ -2938,6 +2939,15 @@ const paperTradingPlugin = {
|
|
|
2938
2939
|
catch (err) {
|
|
2939
2940
|
logger.warn(TAG, `credential transport key setup failed (plaintext fallback stays available): ${formatError(err)}`);
|
|
2940
2941
|
}
|
|
2942
|
+
// Release version + install channel for the dashboard's update banner —
|
|
2943
|
+
// read from the manifest shipped next to index.js (stamped by both release
|
|
2944
|
+
// channels); the repo's unstamped manifest marks a source/dist deploy, which
|
|
2945
|
+
// the dashboard never nags (plugin/src/plugin-version.ts).
|
|
2946
|
+
const installFacts = resolvePluginInstallFacts({
|
|
2947
|
+
pluginRoot: dirname(fileURLToPath(import.meta.url)),
|
|
2948
|
+
connectorSupervisor: readPluginConfig().connectorSupervisor,
|
|
2949
|
+
});
|
|
2950
|
+
logger.info(TAG, `Plugin release ${installFacts.version ?? 'unknown (no stamped manifest)'} via ${installFacts.channel}`);
|
|
2941
2951
|
// Runs here once (guarded by the pluginInitialised early-return → once per
|
|
2942
2952
|
// process) + on an unref'd interval inside the reporter.
|
|
2943
2953
|
startReadinessReporter({
|
|
@@ -2946,6 +2956,8 @@ const paperTradingPlugin = {
|
|
|
2946
2956
|
venue,
|
|
2947
2957
|
publicApi: hlPublicApi ?? binanceApi,
|
|
2948
2958
|
toolCount: toolNames.length,
|
|
2959
|
+
pluginVersion: installFacts.version,
|
|
2960
|
+
installChannel: installFacts.channel,
|
|
2949
2961
|
// live_stop_protection (E2E audit #3): what would stop a losing live
|
|
2950
2962
|
// position. Deferred closure over the runtime so paper↔live flips and
|
|
2951
2963
|
// adapter swaps surface on the next 5-min report without a restart.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type ReadinessCheck, type ReadinessReport, type VenueId, type VenueReachabilityResult } from '@reefclaw/shared';
|
|
2
|
+
import type { InstallChannel } from '../plugin-version.js';
|
|
2
3
|
/** Who froze the loop. 'unknown' when the kernel counter is unreadable (not
|
|
3
4
|
* Linux / no CONFIG_SCHEDSTATS / first cycle) — attribution is evidence, and
|
|
4
5
|
* absent evidence stays absent rather than defaulting to a blame. */
|
|
@@ -48,6 +49,12 @@ export interface ReadinessReporterOptions {
|
|
|
48
49
|
publicApi: VenueReachabilityProbe;
|
|
49
50
|
/** Number of trading tools registered (a health signal). */
|
|
50
51
|
toolCount: number;
|
|
52
|
+
/** Release version from the shipped manifest (e.g. '0.1.27'); omitted from
|
|
53
|
+
* the report when unknown — never fabricated. */
|
|
54
|
+
pluginVersion?: string;
|
|
55
|
+
/** How this plugin was installed — decides which update path the dashboard
|
|
56
|
+
* offers ('npx' one-click, 'clawhub' steps, 'source' silent). */
|
|
57
|
+
installChannel?: InstallChannel;
|
|
51
58
|
/** Resolve the stop-protection snapshot at CALL time (deferred closure over
|
|
52
59
|
* the runtime — follows paper↔live flips and adapter swaps). Absent/null →
|
|
53
60
|
* the `live_stop_protection` row is omitted, never fabricated. */
|
|
@@ -67,7 +74,7 @@ export interface ReadinessReporterOptions {
|
|
|
67
74
|
* warn/fail drift is reported 'unknown' (not amber/red) so the readiness
|
|
68
75
|
* banner doesn't cry-wolf for ~5 min after every restart; a genuinely
|
|
69
76
|
* skewed clock still surfaces on cycle 2. */
|
|
70
|
-
export declare function collectReadiness(opts: Pick<ReadinessReporterOptions, 'venue' | 'publicApi' | 'toolCount' | 'resolveStopProtection'>, bootWarmup?: boolean, deps?: {
|
|
77
|
+
export declare function collectReadiness(opts: Pick<ReadinessReporterOptions, 'venue' | 'publicApi' | 'toolCount' | 'resolveStopProtection' | 'pluginVersion' | 'installChannel'>, bootWarmup?: boolean, deps?: {
|
|
71
78
|
/** Debounce memory. Omitted → a fresh state, so a lone unreachable reads
|
|
72
79
|
* `unknown`; only a caller that persists state across cycles can ever
|
|
73
80
|
* reach the warn rung. */
|
|
@@ -14,8 +14,11 @@ import { startEventLoopMonitor, sampleEventLoopDelayMs, sampleRunqueueWaitMs, }
|
|
|
14
14
|
const TAG = 'readiness';
|
|
15
15
|
const DEFAULT_INTERVAL_MS = 300_000; // 5 min — geo/clock state changes rarely.
|
|
16
16
|
const MIN_INTERVAL_MS = 60_000;
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
// The plugin's RELEASE version + install channel arrive via options (index.ts
|
|
18
|
+
// resolves them from the shipped manifest — plugin/src/plugin-version.ts).
|
|
19
|
+
// They used to be a hardcoded internal constant ('3.8.0') in a different
|
|
20
|
+
// namespace from the release versions, which left the dashboard's update
|
|
21
|
+
// banner permanently inert.
|
|
19
22
|
/** Consecutive non-pass reachability probes required before the banner goes
|
|
20
23
|
* amber. One 5-min sample is not enough evidence to send an operator hunting a
|
|
21
24
|
* network fault — the intel-health AMBER rung already debounces the same way
|
|
@@ -288,7 +291,8 @@ export async function collectReadiness(opts, bootWarmup = false, deps = {}) {
|
|
|
288
291
|
overall: deriveOverallReadiness(checks),
|
|
289
292
|
checks,
|
|
290
293
|
agent: {
|
|
291
|
-
pluginVersion:
|
|
294
|
+
...(opts.pluginVersion ? { pluginVersion: opts.pluginVersion } : {}),
|
|
295
|
+
...(opts.installChannel ? { installChannel: opts.installChannel } : {}),
|
|
292
296
|
toolCount: opts.toolCount,
|
|
293
297
|
venue: opts.venue,
|
|
294
298
|
...(credentialPublicKey ? { credentialPublicKey } : {}),
|
|
@@ -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
|
-
|
|
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.
|
|
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;
|