@reefclaw/openclaw-plugin 0.1.23 → 0.1.24
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/bridge/bridge.js +72 -5
- package/bridge/connector.js +14 -2
- package/bridge/gateway/heartbeat-cron.js +30 -7
- package/bridge/gateway/poller.d.ts +5 -0
- package/bridge/gateway/poller.js +9 -0
- package/bridge/provider.d.ts +15 -0
- package/bridge/providers/connector-update.d.ts +89 -0
- package/bridge/providers/connector-update.js +212 -0
- package/bridge/providers/emergency-commands.d.ts +36 -0
- package/bridge/providers/emergency-commands.js +91 -0
- package/bridge/providers/gateway.d.ts +26 -1
- package/bridge/providers/gateway.js +159 -8
- package/bridge/providers/mock.js +1 -0
- package/bridge/types.d.ts +1 -1
- package/bridge/types.js +5 -0
- package/ccxt/binance-private.js +2 -1
- package/ccxt/binance-public.js +6 -1
- package/config/agent-config-client.d.ts +5 -2
- package/config/agent-config-client.js +13 -0
- package/config/agent-config-poller.js +5 -1
- package/config/gate-store.d.ts +9 -0
- package/config/gate-store.js +17 -2
- package/config/plugin-config-io.js +24 -2
- package/http/keepalive-fetch.d.ts +5 -0
- package/http/keepalive-fetch.js +50 -0
- package/index.js +48 -6
- package/ingest/position-decisions-client.d.ts +6 -0
- package/ingest/position-decisions-client.js +27 -9
- package/live/approval-lifecycle.d.ts +10 -0
- package/live/approval-lifecycle.js +16 -2
- package/live/microstructure-assembler.js +11 -2
- package/live/proposal-decision-listener.d.ts +21 -0
- package/live/proposal-decision-listener.js +39 -0
- package/live/proposal-manager.d.ts +12 -0
- package/live/proposal-manager.js +47 -0
- package/live/stop-watcher.d.ts +16 -1
- package/live/stop-watcher.js +48 -8
- package/openclaw.plugin.json +1 -1
- package/package.json +38 -38
- package/persistence/state-manager.d.ts +7 -0
- package/persistence/state-manager.js +28 -1
- package/simulator/exchange-simulator.d.ts +22 -0
- package/simulator/exchange-simulator.js +74 -32
- package/tools/audit-bracket-protection.js +11 -7
- package/tools/create-order.js +49 -7
- package/tools/get-funding-context.js +6 -1
- package/tools/get-liquidation-levels.js +5 -1
- package/tools/get-liquidation-pulse.js +7 -1
- package/tools/get-market-intel.js +2 -1
- package/tools/get-relevant-learnings.js +20 -1
- package/tools/get-resting-liquidity.js +6 -1
- package/tools/get-wave9-status.js +17 -0
- package/tools/intel-api.d.ts +9 -0
- package/tools/intel-api.js +32 -1
- package/tools/record-position-reviews.js +2 -2
- package/tools/scan-pairs.js +20 -11
- package/types.d.ts +7 -0
package/bridge/bridge.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import { writeFileSync, renameSync, existsSync, mkdirSync, unlinkSync, readdirSync } from 'fs';
|
|
12
12
|
import { join } from 'path';
|
|
13
13
|
import { homedir } from 'os';
|
|
14
|
-
import { logger } from './logger.js';
|
|
14
|
+
import { logger, formatError } from './logger.js';
|
|
15
15
|
import { Connector } from './connector.js';
|
|
16
16
|
import { readLocalSkillVersion, readAgentVisibleSkillVersions, validateSkillContent, compareSemver } from './utils/skill-version.js';
|
|
17
17
|
import { invalidateSkillsSnapshot } from './utils/skills-snapshot-invalidation.js';
|
|
@@ -53,6 +53,8 @@ const CRITICAL_EVENTS = new Set([
|
|
|
53
53
|
'state_update', // Agent mode changes (ACTIVE/PAUSED/STOPPED)
|
|
54
54
|
'trading_mode', // Paper/live mode changes
|
|
55
55
|
'skill_update_applied', // OTA SKILL.md confirmation
|
|
56
|
+
'connector_update', // Operator-triggered update progress — the frame right
|
|
57
|
+
// before the gateway restart must not be coalesced away.
|
|
56
58
|
// All chat events are on the 'chat' channel and always sent immediately
|
|
57
59
|
]);
|
|
58
60
|
/** How often to re-attempt the webapp SKILL.md pull while still on a bootstrap
|
|
@@ -249,6 +251,14 @@ export class Bridge {
|
|
|
249
251
|
this.addListener('decisionTraceUpdate', (data) => {
|
|
250
252
|
this.emit('agent_state', 'decision_trace', data);
|
|
251
253
|
});
|
|
254
|
+
// Operator-triggered connector update: PTY output + status. CRITICAL
|
|
255
|
+
// priority so the operator watching an update in progress sees it live
|
|
256
|
+
// instead of behind the 500ms low-priority coalescer — and, more to the
|
|
257
|
+
// point, so the final frame before the gateway restart is not the one that
|
|
258
|
+
// gets dropped.
|
|
259
|
+
this.addListener('connectorUpdate', (data) => {
|
|
260
|
+
this.emit('agent_state', 'connector_update', data);
|
|
261
|
+
});
|
|
252
262
|
}
|
|
253
263
|
addListener(event, fn) {
|
|
254
264
|
this.provider.on(event, fn);
|
|
@@ -313,10 +323,20 @@ export class Bridge {
|
|
|
313
323
|
flushThrottleBuffer() {
|
|
314
324
|
if (this.throttleBuffer.size === 0)
|
|
315
325
|
return;
|
|
316
|
-
|
|
317
|
-
|
|
326
|
+
// Per-entry guard + delete-as-sent: this runs bare inside setInterval, so
|
|
327
|
+
// an uncaught throw (e.g. JSON.stringify on a payload with a circular ref
|
|
328
|
+
// or BigInt leaking out of a tool result) would be a process-killing
|
|
329
|
+
// uncaught exception — and skipping clear() would re-flush the survivors
|
|
330
|
+
// next tick under NEW seq numbers (duplicate frames on the wire).
|
|
331
|
+
for (const [key, { channel, event, payload }] of this.throttleBuffer) {
|
|
332
|
+
this.throttleBuffer.delete(key);
|
|
333
|
+
try {
|
|
334
|
+
this.sendFrame(channel, event, payload);
|
|
335
|
+
}
|
|
336
|
+
catch (err) {
|
|
337
|
+
logger.warn(TAG, `flush dropped unserializable ${channel}:${event}: ${formatError(err)}`);
|
|
338
|
+
}
|
|
318
339
|
}
|
|
319
|
-
this.throttleBuffer.clear();
|
|
320
340
|
}
|
|
321
341
|
startThrottleTimer() {
|
|
322
342
|
this.stopThrottleTimer();
|
|
@@ -361,7 +381,7 @@ export class Bridge {
|
|
|
361
381
|
logger.info(TAG, 'Reconciliation requested');
|
|
362
382
|
const snapshot = await this.provider.getSnapshot();
|
|
363
383
|
const lastSequences = parseLastSequences(params);
|
|
364
|
-
|
|
384
|
+
let gapFill = this.replayBuffer
|
|
365
385
|
.sinceMany(lastSequences)
|
|
366
386
|
.map((e) => ({
|
|
367
387
|
type: 'event',
|
|
@@ -370,7 +390,32 @@ export class Bridge {
|
|
|
370
390
|
payload: e.payload,
|
|
371
391
|
seq: e.seq,
|
|
372
392
|
}));
|
|
393
|
+
// The whole reconcile response is ONE WS message; an unbounded replay
|
|
394
|
+
// blows the relay's frame limit and the browser then gets NO snapshot
|
|
395
|
+
// at all (observed live 2026-08-04: 369 replayed events → relay
|
|
396
|
+
// "Message too large"). Keep the NEWEST events under a byte budget —
|
|
397
|
+
// the snapshot already carries current state, so dropping the oldest
|
|
398
|
+
// stream events is strictly better than losing the entire response.
|
|
373
399
|
if (gapFill.length > 0) {
|
|
400
|
+
const GAP_FILL_BYTE_BUDGET = 256 * 1024;
|
|
401
|
+
let bytes = 0;
|
|
402
|
+
let start = gapFill.length;
|
|
403
|
+
for (let i = gapFill.length - 1; i >= 0; i--) {
|
|
404
|
+
try {
|
|
405
|
+
bytes += JSON.stringify(gapFill[i]).length;
|
|
406
|
+
}
|
|
407
|
+
catch {
|
|
408
|
+
start = i + 1; // unserializable event — cut it and everything older
|
|
409
|
+
break;
|
|
410
|
+
}
|
|
411
|
+
if (bytes > GAP_FILL_BYTE_BUDGET)
|
|
412
|
+
break;
|
|
413
|
+
start = i;
|
|
414
|
+
}
|
|
415
|
+
if (start > 0) {
|
|
416
|
+
logger.warn(TAG, `gapFill capped: dropping ${start} oldest of ${gapFill.length} events to fit the relay frame limit`);
|
|
417
|
+
gapFill = gapFill.slice(start);
|
|
418
|
+
}
|
|
374
419
|
logger.info(TAG, `gapFill: ${gapFill.length} events replayed (lastSeqs=${JSON.stringify(lastSequences)})`);
|
|
375
420
|
}
|
|
376
421
|
// Provider hardcodes gapFill: []; bridge owns the wire-side seq
|
|
@@ -557,6 +602,28 @@ export class Bridge {
|
|
|
557
602
|
this.connector.sendResponse(id, true, result);
|
|
558
603
|
return;
|
|
559
604
|
}
|
|
605
|
+
if (method === 'connector.update') {
|
|
606
|
+
const provider = this.provider;
|
|
607
|
+
if (!provider.updateConnector) {
|
|
608
|
+
this.connector.sendResponse(id, true, {
|
|
609
|
+
ok: false,
|
|
610
|
+
status: 'failed',
|
|
611
|
+
message: 'Provider does not support connector.update (likely mock provider)',
|
|
612
|
+
});
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
// ★ Deliberately reads ONE field. There is no command/version/args
|
|
616
|
+
// parameter to forward — the command is a constant inside the driver
|
|
617
|
+
// (providers/connector-update.ts). If a future change starts forwarding
|
|
618
|
+
// caller-supplied strings into that command, this stops being an update
|
|
619
|
+
// button and becomes a remote shell on the trader's machine.
|
|
620
|
+
const acknowledgeOpenPositions = params?.acknowledgeOpenPositions === true;
|
|
621
|
+
audit('connector_update.start', { id, acknowledgeOpenPositions });
|
|
622
|
+
const outcome = await provider.updateConnector({ acknowledgeOpenPositions });
|
|
623
|
+
audit('connector_update.dispatched', { id, ok: outcome.ok, status: outcome.status });
|
|
624
|
+
this.connector.sendResponse(id, true, outcome);
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
560
627
|
if (method === 'get_bracket_config') {
|
|
561
628
|
if (!this.provider.getBracketConfig) {
|
|
562
629
|
this.connector.sendResponse(id, false, undefined, {
|
package/bridge/connector.js
CHANGED
|
@@ -78,10 +78,22 @@ export class Connector {
|
|
|
78
78
|
this.rejectAllPending('Connection closed');
|
|
79
79
|
if (this.destroyed)
|
|
80
80
|
return;
|
|
81
|
-
// Fatal codes:
|
|
81
|
+
// Fatal codes (4001 invalid/mismatched token): no fast reconnect — but
|
|
82
|
+
// not terminal-forever either. A 4001 can be transient (relay/DB blip,
|
|
83
|
+
// token-cache miss on a just-created token, revoke-then-restore), and
|
|
84
|
+
// the old return-and-never-retry left the dashboard dark until a manual
|
|
85
|
+
// bridge restart (residual half of the #312 gave-up-forever class). One
|
|
86
|
+
// probe per 15 min is harmless and self-heals the transient cases.
|
|
82
87
|
if (FATAL_CLOSE_CODES.includes(code)) {
|
|
83
|
-
logger.error(TAG, `Fatal close code ${code}
|
|
88
|
+
logger.error(TAG, `Fatal close code ${code} (invalid/mismatched token). If the token was rotated, ` +
|
|
89
|
+
`update the bridge .env (REEFCLAW_TOKEN) and restart. Probing again in 15 min ` +
|
|
90
|
+
`in case the rejection was transient.`);
|
|
84
91
|
this.setState('failed');
|
|
92
|
+
this.attempt = 0;
|
|
93
|
+
this.reconnectTimer = setTimeout(() => {
|
|
94
|
+
this.reconnectTimer = null;
|
|
95
|
+
this.connect();
|
|
96
|
+
}, 15 * 60_000);
|
|
85
97
|
return;
|
|
86
98
|
}
|
|
87
99
|
// 4002 = stale skill connection on relay. Wait for it to time out, then retry.
|
|
@@ -28,11 +28,34 @@ export const HEARTBEAT_EVERY_MS = 15 * 60 * 1000;
|
|
|
28
28
|
// (verified empirically on 2026.7.1-2: a cron.add without it stores none) —
|
|
29
29
|
// 780s is the field-proven value from the 2026-07-22 small-box mitigations.
|
|
30
30
|
export const HEARTBEAT_TIMEOUT_SECONDS = 780;
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
31
|
+
// The routine beat is HEARTBEAT.md (injected into every isolated cron session
|
|
32
|
+
// as workspace bootstrap context) — NOT the SKILL.md Decision Loop. The old
|
|
33
|
+
// message ("run the full Decision Loop (Steps 0-9) from SKILL.md") had two
|
|
34
|
+
// measured costs on prod (2026-08-04): the agent paged the whole ~110KB
|
|
35
|
+
// SKILL.md through sed/rg every beat (~100KB of tool output before any
|
|
36
|
+
// trading work), and Steps 0-9 predate the pinned-plan/exit-gate discipline —
|
|
37
|
+
// Step 8's exit rules are the scratch-the-winners instructions. SKILL.md is
|
|
38
|
+
// consulted only when preparing a NEW entry.
|
|
39
|
+
// NOTE: once-per-install marker semantics mean this message only reaches NEW
|
|
40
|
+
// installs; existing boxes need a one-time `openclaw cron edit <id> --message`
|
|
41
|
+
// (done on prod at deploy).
|
|
42
|
+
// ★ The mandatory items are ENUMERATED here on purpose. The first (softer)
|
|
43
|
+
// version of this message — "Run the HEARTBEAT.md checklist" — produced
|
|
44
|
+
// 85-second beats on gpt-5.4-mini that skipped record_position_reviews,
|
|
45
|
+
// get_wave9_status, and the liquidity batch entirely (observed on prod
|
|
46
|
+
// 2026-08-04 12:25/12:40 UTC): the checklist being in context is not enough,
|
|
47
|
+
// the beat prompt itself must name what cannot be skipped.
|
|
48
|
+
export const HEARTBEAT_MESSAGE = 'Heartbeat. Execute EVERY checkbox in the HEARTBEAT.md checklist (already injected in your context; ' +
|
|
49
|
+
'do NOT re-read SKILL.md from disk unless preparing a NEW entry). ' +
|
|
50
|
+
'NON-SKIPPABLE every beat: (1) query_trades({hours:1}) stop-watcher reconcile; ' +
|
|
51
|
+
'(2) get_wave9_status() once, unconditionally; ' +
|
|
52
|
+
'(3) if ANY position is open: get_my_recent_reviews() + get_relevant_learnings({applies_at: heartbeat}) in one batch, ' +
|
|
53
|
+
'then record_position_reviews with ONE review per open position, ' +
|
|
54
|
+
'plus get_resting_liquidity + get_liquidation_levels + get_liquidation_pulse for ALL positions in one parallel batch; ' +
|
|
55
|
+
'(4) the Market Assessment reads. ' +
|
|
56
|
+
'Manage open positions by their pinned plan (invalidation_price / realization_rule) — do not re-argue the entry thesis each beat. ' +
|
|
57
|
+
'Check tradingMode from fetch_balance — the source of truth for paper vs live. ' +
|
|
58
|
+
'Batch independent tool calls in parallel. Trading tools only: no subagents, no web browsing.';
|
|
36
59
|
/**
|
|
37
60
|
* Whether an existing cron job counts as "the heartbeat already exists".
|
|
38
61
|
* Union of the two matchers already in the codebase: the old setup path
|
|
@@ -89,8 +112,8 @@ export async function ensureHeartbeatCron(opts) {
|
|
|
89
112
|
// cron.add on 2026.7.1-2). No toolsAllow: absent = all-tools-allowed,
|
|
90
113
|
// which is future-proof — an explicit allowlist freezes at creation and
|
|
91
114
|
// silently excludes every plugin tool shipped later. Beat tool
|
|
92
|
-
// discipline (no subagents/web
|
|
93
|
-
//
|
|
115
|
+
// discipline (no subagents/web during heartbeats) is stated in
|
|
116
|
+
// HEARTBEAT_MESSAGE itself.
|
|
94
117
|
payload: {
|
|
95
118
|
kind: 'agentTurn',
|
|
96
119
|
message: HEARTBEAT_MESSAGE,
|
|
@@ -114,6 +114,11 @@ export declare class Poller {
|
|
|
114
114
|
* - Errors are logged and swallowed (never crash the provider)
|
|
115
115
|
*/
|
|
116
116
|
private guardedExec;
|
|
117
|
+
/** Arm the circuit-breaker for a Binance-bound call made OUTSIDE
|
|
118
|
+
* guardedExec (e.g. the gateway's paper mark-to-market ticker fan-out).
|
|
119
|
+
* Those calls used to swallow 418/429s entirely — exactly the
|
|
120
|
+
* ban-extending failure mode the gate exists to prevent. */
|
|
121
|
+
noteExternalRateLimit(label: string, msg: string): void;
|
|
117
122
|
/** Inspect a failed poll's error for Binance rate-limit signals and arm
|
|
118
123
|
* the process-wide circuit-breaker accordingly.
|
|
119
124
|
*
|
package/bridge/gateway/poller.js
CHANGED
|
@@ -266,6 +266,15 @@ export class Poller {
|
|
|
266
266
|
this.inflight.delete(label);
|
|
267
267
|
}
|
|
268
268
|
}
|
|
269
|
+
/** Arm the circuit-breaker for a Binance-bound call made OUTSIDE
|
|
270
|
+
* guardedExec (e.g. the gateway's paper mark-to-market ticker fan-out).
|
|
271
|
+
* Those calls used to swallow 418/429s entirely — exactly the
|
|
272
|
+
* ban-extending failure mode the gate exists to prevent. */
|
|
273
|
+
noteExternalRateLimit(label, msg) {
|
|
274
|
+
if (isRateLimitShapedError(msg)) {
|
|
275
|
+
this.applyRateLimitBackoff(label, msg);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
269
278
|
/** Inspect a failed poll's error for Binance rate-limit signals and arm
|
|
270
279
|
* the process-wide circuit-breaker accordingly.
|
|
271
280
|
*
|
package/bridge/provider.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { EmergencyAction, ReconciliationSnapshot, TickerData, CandleData, OrderUpdatePayload, AgentStateData, RiskUpdatePayload, ChatMessage, MarketStructureData, CryptoMetricsData, VolumeAnalysisData, TradeJournalEntry, RegimeData, SignalData, MissionData, AnalyticsData, ShadowComparisonData, TradingModeData, DecisionTraceData } from './types.js';
|
|
2
2
|
import type { TradingMode } from '@reefclaw/shared';
|
|
3
|
+
import type { ConnectorUpdateOutcome } from './providers/connector-update.js';
|
|
3
4
|
/** Per-venue credential shapes for the operator set/test RPCs. Binance is an
|
|
4
5
|
* HMAC key pair; Hyperliquid is a MASTER account address (public — queries)
|
|
5
6
|
* plus an approved AGENT wallet private key (signs only, cannot withdraw).
|
|
@@ -48,6 +49,9 @@ export interface HlSubmitApprovalOutcome {
|
|
|
48
49
|
chain?: string;
|
|
49
50
|
agentAddress?: string;
|
|
50
51
|
}
|
|
52
|
+
/** Progress of an operator-triggered connector update. Re-exported from the
|
|
53
|
+
* driver so callers get the status union without importing the PTY module. */
|
|
54
|
+
export type { ConnectorUpdateOutcome, ConnectorUpdateStatus, } from './providers/connector-update.js';
|
|
51
55
|
/** Result of hl_agent_wallet_status — approval polling for the guided flow. */
|
|
52
56
|
export interface HlAgentWalletStatusOutcome {
|
|
53
57
|
ok: boolean;
|
|
@@ -100,6 +104,8 @@ export interface ProviderEvents {
|
|
|
100
104
|
id: string;
|
|
101
105
|
toolName: string;
|
|
102
106
|
}) => void;
|
|
107
|
+
/** Progress of an operator-triggered connector update (PTY output + status). */
|
|
108
|
+
connectorUpdate: (data: ConnectorUpdateOutcome) => void;
|
|
103
109
|
marketStructure: (data: MarketStructureData) => void;
|
|
104
110
|
cryptoMetrics: (data: CryptoMetricsData) => void;
|
|
105
111
|
volumeAnalysis: (data: VolumeAnalysisData) => void;
|
|
@@ -217,6 +223,15 @@ export interface OpenClawProvider {
|
|
|
217
223
|
v: number;
|
|
218
224
|
};
|
|
219
225
|
}): Promise<HlSubmitApprovalOutcome>;
|
|
226
|
+
/** Operator-only: update the ReefClaw connector on this box to the latest
|
|
227
|
+
* published version, driven over the gateway's terminal.* PTY methods.
|
|
228
|
+
*
|
|
229
|
+
* There is no command parameter, by design — see the security note at the
|
|
230
|
+
* top of providers/connector-update.ts. The only input is the operator's
|
|
231
|
+
* acknowledgement that the resulting restart may interrupt an open book. */
|
|
232
|
+
updateConnector?(args: {
|
|
233
|
+
acknowledgeOpenPositions?: boolean;
|
|
234
|
+
}): Promise<ConnectorUpdateOutcome>;
|
|
220
235
|
/** Operator-only: remove stored credentials from the plugin config and
|
|
221
236
|
* de-escalate to PAPER mode if currently running in a live mode. */
|
|
222
237
|
clearExchangeCredentials?(): Promise<{
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE command this feature can ever run.
|
|
3
|
+
*
|
|
4
|
+
* `-y` so npx never blocks on its install prompt; `@latest` because this is an
|
|
5
|
+
* update. No user input, no interpolation, no exceptions.
|
|
6
|
+
*/
|
|
7
|
+
export declare const CONNECTOR_UPDATE_COMMAND = "npx -y @reefclaw/connect@latest";
|
|
8
|
+
/** Marker printed after the command so we can detect completion + exit code. */
|
|
9
|
+
export declare const DONE_SENTINEL = "__RC_CONNECTOR_UPDATE_DONE__";
|
|
10
|
+
/**
|
|
11
|
+
* The exact line written to the PTY. Built once, from constants only.
|
|
12
|
+
* `$?` is expanded by the remote shell, not by us.
|
|
13
|
+
*/
|
|
14
|
+
export declare function buildUpdateCommandLine(): string;
|
|
15
|
+
/** Minimal RPC surface this module needs (injected — keeps it unit-testable). */
|
|
16
|
+
export type TerminalRpc = (method: string, params: Record<string, unknown>) => Promise<unknown>;
|
|
17
|
+
export type ConnectorUpdateStatus =
|
|
18
|
+
/** Command written to the PTY; output is streaming. */
|
|
19
|
+
'started'
|
|
20
|
+
/** DONE sentinel observed — we have a real exit code. */
|
|
21
|
+
| 'completed'
|
|
22
|
+
/** Transport died after the command started = the gateway restart landed. */
|
|
23
|
+
| 'restarting'
|
|
24
|
+
/** Refused before anything ran (terminal disabled / sandboxed / open positions). */
|
|
25
|
+
| 'blocked'
|
|
26
|
+
/** Something genuinely went wrong. */
|
|
27
|
+
| 'failed';
|
|
28
|
+
export interface ConnectorUpdateOutcome {
|
|
29
|
+
ok: boolean;
|
|
30
|
+
status: ConnectorUpdateStatus;
|
|
31
|
+
message: string;
|
|
32
|
+
sessionId?: string;
|
|
33
|
+
exitCode?: number | null;
|
|
34
|
+
/** Accumulated screen text, for the dashboard's output pane. */
|
|
35
|
+
output?: string;
|
|
36
|
+
/** True when the caller must re-submit with acknowledgeOpenPositions. */
|
|
37
|
+
requiresPositionAck?: boolean;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Map a gateway error from `terminal.open` onto an operator-readable outcome.
|
|
41
|
+
*
|
|
42
|
+
* The two refusals we know the gateway can produce are worth distinct copy —
|
|
43
|
+
* both are configuration facts on the trader's box that no retry will fix:
|
|
44
|
+
* - "terminal is not available" / "terminal is disabled"
|
|
45
|
+
* - agent runs in a sandbox → in-sandbox terminals are unsupported
|
|
46
|
+
*/
|
|
47
|
+
export declare function describeOpenFailure(err: unknown): ConnectorUpdateOutcome;
|
|
48
|
+
/**
|
|
49
|
+
* Once the command is running, losing the transport is EXPECTED — the installer
|
|
50
|
+
* restarts the gateway, which is the process hosting both the PTY and our own
|
|
51
|
+
* connection. Treating that as a failure would report a successful update as
|
|
52
|
+
* broken, so the distinction is explicit and tested.
|
|
53
|
+
*/
|
|
54
|
+
export declare function classifyTransportLoss(started: boolean): ConnectorUpdateStatus;
|
|
55
|
+
/** Extract the exit code that follows the DONE sentinel, if it has appeared. */
|
|
56
|
+
export declare function parseDoneSentinel(screen: string): {
|
|
57
|
+
done: boolean;
|
|
58
|
+
exitCode: number | null;
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* The sentinel is echoed by the shell as part of the command line BEFORE the
|
|
62
|
+
* command runs, so a naive `includes()` reports "done" immediately. Strip the
|
|
63
|
+
* echoed command line first: completion is only credible after the printf has
|
|
64
|
+
* actually executed, which is the LAST occurrence and is followed by a digit.
|
|
65
|
+
*/
|
|
66
|
+
export declare function isCredibleCompletion(screen: string): boolean;
|
|
67
|
+
export interface StartOptions {
|
|
68
|
+
/** Open positions the operator has been warned about and accepted. */
|
|
69
|
+
acknowledgeOpenPositions?: boolean;
|
|
70
|
+
/** Count of currently-open positions, as the skill sees them. */
|
|
71
|
+
openPositionCount: number;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Guard the restart hazard. The installer restarts the gateway; with positions
|
|
75
|
+
* open that means the agent stops managing them for the duration (exchange-side
|
|
76
|
+
* brackets still protect the book — they are enforced by the venue, not by us —
|
|
77
|
+
* but there is a known startup window where protective closes are refused).
|
|
78
|
+
* So: never silently update a live book. Demand an explicit acknowledgement.
|
|
79
|
+
*/
|
|
80
|
+
export declare function checkPositionGuard(opts: StartOptions): ConnectorUpdateOutcome | null;
|
|
81
|
+
/**
|
|
82
|
+
* Open a PTY and write the update command to it. Returns as soon as the command
|
|
83
|
+
* is running — the caller polls `readTerminalText` for output.
|
|
84
|
+
*/
|
|
85
|
+
export declare function startConnectorUpdate(rpc: TerminalRpc, opts: StartOptions): Promise<ConnectorUpdateOutcome>;
|
|
86
|
+
/** Read the current screen contents. Returns null when the session is gone. */
|
|
87
|
+
export declare function readTerminalText(rpc: TerminalRpc, sessionId: string): Promise<string | null>;
|
|
88
|
+
/** Best-effort PTY cleanup. Never throws — cleanup failure must not mask an outcome. */
|
|
89
|
+
export declare function closeTerminal(rpc: TerminalRpc, sessionId: string): Promise<void>;
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// Operator-triggered connector update, driven over the OpenClaw gateway's
|
|
2
|
+
// terminal.* PTY methods.
|
|
3
|
+
//
|
|
4
|
+
// WHY A PTY AND NOT AN RPC: there is no plugin-install RPC on the gateway. The
|
|
5
|
+
// full method table of OpenClaw 2026.7.1-2 (197 handlers) has no
|
|
6
|
+
// `plugin.install` / `plugins.install`; that string exists only as an
|
|
7
|
+
// audit-operation label and a CLI flag. `skills.install` DOES accept
|
|
8
|
+
// `acknowledgeClawHubRisk` (it is what OpenClaw's own Control UI calls) but it
|
|
9
|
+
// is the SKILLS surface, and the ClawHub installer branches on package family —
|
|
10
|
+
// ReefClaw ships as `code-plugin`, which that path refuses by design. On this
|
|
11
|
+
// OpenClaw version a code-plugin can only be installed/updated from a shell.
|
|
12
|
+
// `terminal.*` is the gateway's shell.
|
|
13
|
+
//
|
|
14
|
+
// ★★ SECURITY — READ BEFORE CHANGING ANYTHING IN THIS FILE ★★
|
|
15
|
+
// This module can run a command on the trader's machine, initiated from our
|
|
16
|
+
// dashboard. That is only acceptable because the command is a FIXED CONSTANT:
|
|
17
|
+
//
|
|
18
|
+
// - `CONNECTOR_UPDATE_COMMAND` is a module constant. Nothing from the
|
|
19
|
+
// browser, the relay, the agent, or any config is interpolated into it.
|
|
20
|
+
// There is deliberately NO version/args/flags parameter — not even a
|
|
21
|
+
// validated one — because the moment a caller can influence the string this
|
|
22
|
+
// stops being an update button and becomes a remote shell.
|
|
23
|
+
// - The RPC that reaches this module carries NO command field. Adding one is
|
|
24
|
+
// not a feature request to accept; it is the vulnerability.
|
|
25
|
+
// - No secret is ever placed on the command line. `@reefclaw/connect` takes
|
|
26
|
+
// no token argument (the token is already on disk from the first install),
|
|
27
|
+
// so the process list and shell history stay clean.
|
|
28
|
+
//
|
|
29
|
+
// Two further facts that shape the design:
|
|
30
|
+
// - The installer calls `restartGateway()`. The gateway restart therefore
|
|
31
|
+
// KILLS THIS PTY AND OUR OWN TRANSPORT mid-run. Losing the session after
|
|
32
|
+
// the command started is the SUCCESS path, not an error — see
|
|
33
|
+
// `classifyTransportLoss`.
|
|
34
|
+
// - The PTY runs as the gateway's own user (`openclaw`), which is exactly the
|
|
35
|
+
// user the installer must run as. Correct by construction.
|
|
36
|
+
import { logger } from '../logger.js';
|
|
37
|
+
const TAG = 'connector-update';
|
|
38
|
+
/**
|
|
39
|
+
* The ONE command this feature can ever run.
|
|
40
|
+
*
|
|
41
|
+
* `-y` so npx never blocks on its install prompt; `@latest` because this is an
|
|
42
|
+
* update. No user input, no interpolation, no exceptions.
|
|
43
|
+
*/
|
|
44
|
+
export const CONNECTOR_UPDATE_COMMAND = 'npx -y @reefclaw/connect@latest';
|
|
45
|
+
/** Marker printed after the command so we can detect completion + exit code. */
|
|
46
|
+
export const DONE_SENTINEL = '__RC_CONNECTOR_UPDATE_DONE__';
|
|
47
|
+
/**
|
|
48
|
+
* The exact line written to the PTY. Built once, from constants only.
|
|
49
|
+
* `$?` is expanded by the remote shell, not by us.
|
|
50
|
+
*/
|
|
51
|
+
export function buildUpdateCommandLine() {
|
|
52
|
+
return `${CONNECTOR_UPDATE_COMMAND}; printf '\\n${DONE_SENTINEL}%s\\n' "$?"`;
|
|
53
|
+
}
|
|
54
|
+
/** Terminal grid we request. Wide enough that npm/npx output does not wrap into
|
|
55
|
+
* unreadable soup in the dashboard's output pane. */
|
|
56
|
+
const COLS = 120;
|
|
57
|
+
const ROWS = 40;
|
|
58
|
+
function isOpenResult(v) {
|
|
59
|
+
return !!v && typeof v === 'object' && typeof v.sessionId === 'string';
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Map a gateway error from `terminal.open` onto an operator-readable outcome.
|
|
63
|
+
*
|
|
64
|
+
* The two refusals we know the gateway can produce are worth distinct copy —
|
|
65
|
+
* both are configuration facts on the trader's box that no retry will fix:
|
|
66
|
+
* - "terminal is not available" / "terminal is disabled"
|
|
67
|
+
* - agent runs in a sandbox → in-sandbox terminals are unsupported
|
|
68
|
+
*/
|
|
69
|
+
export function describeOpenFailure(err) {
|
|
70
|
+
const raw = err instanceof Error ? err.message : String(err);
|
|
71
|
+
const lower = raw.toLowerCase();
|
|
72
|
+
if (lower.includes('terminal is disabled') || lower.includes('terminal is not available')) {
|
|
73
|
+
return {
|
|
74
|
+
ok: false,
|
|
75
|
+
status: 'blocked',
|
|
76
|
+
message: 'This box has the OpenClaw terminal disabled, so ReefClaw cannot run the update for you. '
|
|
77
|
+
+ 'Update it from a shell on the box instead: npx -y @reefclaw/connect@latest',
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
if (lower.includes('sandbox')) {
|
|
81
|
+
return {
|
|
82
|
+
ok: false,
|
|
83
|
+
status: 'blocked',
|
|
84
|
+
message: 'This agent runs in a sandbox, and OpenClaw does not support terminals inside one yet. '
|
|
85
|
+
+ 'Update from a shell on the box instead: npx -y @reefclaw/connect@latest',
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
ok: false,
|
|
90
|
+
status: 'failed',
|
|
91
|
+
message: `Could not open a terminal on the box: ${raw}`,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Once the command is running, losing the transport is EXPECTED — the installer
|
|
96
|
+
* restarts the gateway, which is the process hosting both the PTY and our own
|
|
97
|
+
* connection. Treating that as a failure would report a successful update as
|
|
98
|
+
* broken, so the distinction is explicit and tested.
|
|
99
|
+
*/
|
|
100
|
+
export function classifyTransportLoss(started) {
|
|
101
|
+
return started ? 'restarting' : 'failed';
|
|
102
|
+
}
|
|
103
|
+
/** Extract the exit code that follows the DONE sentinel, if it has appeared. */
|
|
104
|
+
export function parseDoneSentinel(screen) {
|
|
105
|
+
const idx = screen.lastIndexOf(DONE_SENTINEL);
|
|
106
|
+
if (idx === -1)
|
|
107
|
+
return { done: false, exitCode: null };
|
|
108
|
+
const after = screen.slice(idx + DONE_SENTINEL.length);
|
|
109
|
+
const m = /^(\d{1,3})/.exec(after.trim());
|
|
110
|
+
return { done: true, exitCode: m ? Number(m[1]) : null };
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* The sentinel is echoed by the shell as part of the command line BEFORE the
|
|
114
|
+
* command runs, so a naive `includes()` reports "done" immediately. Strip the
|
|
115
|
+
* echoed command line first: completion is only credible after the printf has
|
|
116
|
+
* actually executed, which is the LAST occurrence and is followed by a digit.
|
|
117
|
+
*/
|
|
118
|
+
export function isCredibleCompletion(screen) {
|
|
119
|
+
const { done, exitCode } = parseDoneSentinel(screen);
|
|
120
|
+
return done && exitCode !== null;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Guard the restart hazard. The installer restarts the gateway; with positions
|
|
124
|
+
* open that means the agent stops managing them for the duration (exchange-side
|
|
125
|
+
* brackets still protect the book — they are enforced by the venue, not by us —
|
|
126
|
+
* but there is a known startup window where protective closes are refused).
|
|
127
|
+
* So: never silently update a live book. Demand an explicit acknowledgement.
|
|
128
|
+
*/
|
|
129
|
+
export function checkPositionGuard(opts) {
|
|
130
|
+
if (opts.openPositionCount > 0 && opts.acknowledgeOpenPositions !== true) {
|
|
131
|
+
const n = opts.openPositionCount;
|
|
132
|
+
return {
|
|
133
|
+
ok: false,
|
|
134
|
+
status: 'blocked',
|
|
135
|
+
requiresPositionAck: true,
|
|
136
|
+
message: `${n} position${n === 1 ? ' is' : 's are'} open. Updating restarts the agent, so it will stop `
|
|
137
|
+
+ 'managing the book for about a minute (exchange-side stops stay in force throughout). '
|
|
138
|
+
+ 'Confirm again to update anyway, or flatten first.',
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Open a PTY and write the update command to it. Returns as soon as the command
|
|
145
|
+
* is running — the caller polls `readTerminalText` for output.
|
|
146
|
+
*/
|
|
147
|
+
export async function startConnectorUpdate(rpc, opts) {
|
|
148
|
+
const blocked = checkPositionGuard(opts);
|
|
149
|
+
if (blocked) {
|
|
150
|
+
logger.info(TAG, `update refused: ${opts.openPositionCount} open position(s), no acknowledgement`);
|
|
151
|
+
return blocked;
|
|
152
|
+
}
|
|
153
|
+
let opened;
|
|
154
|
+
try {
|
|
155
|
+
opened = await rpc('terminal.open', { cols: COLS, rows: ROWS });
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
const outcome = describeOpenFailure(err);
|
|
159
|
+
logger.warn(TAG, `terminal.open failed: ${outcome.message}`);
|
|
160
|
+
return outcome;
|
|
161
|
+
}
|
|
162
|
+
if (!isOpenResult(opened)) {
|
|
163
|
+
return {
|
|
164
|
+
ok: false,
|
|
165
|
+
status: 'failed',
|
|
166
|
+
message: 'The gateway opened a terminal but did not return a session id.',
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
const { sessionId } = opened;
|
|
170
|
+
logger.info(TAG, `terminal opened (shell=${opened.shell} cwd=${opened.cwd} confined=${opened.confined})`);
|
|
171
|
+
try {
|
|
172
|
+
// `\r` submits the line — terminal.input is raw keystrokes, not a command API.
|
|
173
|
+
await rpc('terminal.input', { sessionId, data: `${buildUpdateCommandLine()}\r` });
|
|
174
|
+
}
|
|
175
|
+
catch (err) {
|
|
176
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
177
|
+
logger.warn(TAG, `terminal.input failed: ${reason}`);
|
|
178
|
+
// Best-effort cleanup so we do not leak a live PTY on the box.
|
|
179
|
+
await rpc('terminal.close', { sessionId }).catch(() => undefined);
|
|
180
|
+
return { ok: false, status: 'failed', message: `Could not start the update: ${reason}`, sessionId };
|
|
181
|
+
}
|
|
182
|
+
logger.info(TAG, `connector update started (session=${sessionId})`);
|
|
183
|
+
return {
|
|
184
|
+
ok: true,
|
|
185
|
+
status: 'started',
|
|
186
|
+
sessionId,
|
|
187
|
+
message: 'Update started. The agent restarts when it finishes, so the dashboard will reconnect on its own.',
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
/** Read the current screen contents. Returns null when the session is gone. */
|
|
191
|
+
export async function readTerminalText(rpc, sessionId) {
|
|
192
|
+
try {
|
|
193
|
+
const res = await rpc('terminal.text', { sessionId });
|
|
194
|
+
if (typeof res === 'string')
|
|
195
|
+
return res;
|
|
196
|
+
if (res && typeof res === 'object') {
|
|
197
|
+
const t = res;
|
|
198
|
+
if (typeof t.text === 'string')
|
|
199
|
+
return t.text;
|
|
200
|
+
if (typeof t.data === 'string')
|
|
201
|
+
return t.data;
|
|
202
|
+
}
|
|
203
|
+
return '';
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
/** Best-effort PTY cleanup. Never throws — cleanup failure must not mask an outcome. */
|
|
210
|
+
export async function closeTerminal(rpc, sessionId) {
|
|
211
|
+
await rpc('terminal.close', { sessionId }).catch(() => undefined);
|
|
212
|
+
}
|
|
@@ -44,6 +44,42 @@ export declare function executeKill(ctx: EmergencyContext, freshOrders: OrderDat
|
|
|
44
44
|
* Caller must set agentMode = 'STOPPED' after this returns.
|
|
45
45
|
*/
|
|
46
46
|
export declare function executeFlatten(ctx: EmergencyContext, cachedPositions: CcxtPosition[]): Promise<EmergencyResult>;
|
|
47
|
+
export interface ProposalCancelSummary {
|
|
48
|
+
/** Rows moved to `cancelled`. 0 is the normal result when approval mode is off. */
|
|
49
|
+
cancelled: number;
|
|
50
|
+
/** Approved proposals a listener had already claimed — these may already be
|
|
51
|
+
* at the exchange, so they are deliberately NOT cancelled. */
|
|
52
|
+
inFlight: number;
|
|
53
|
+
/** Operator-facing sentence for EmergencyResult.details, or null when there
|
|
54
|
+
* is nothing worth saying (feature off / not configured). */
|
|
55
|
+
detail: string | null;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Cancel the tenant's outstanding approval-mode proposals because trading is
|
|
59
|
+
* being halted.
|
|
60
|
+
*
|
|
61
|
+
* Kill / Flatten / Pause previously left pending proposals alone, so an
|
|
62
|
+
* approval landing seconds after a kill still opened a brand-new position —
|
|
63
|
+
* hard expiry was the only bound (design doc §10 row 13). The plugin holds no
|
|
64
|
+
* halt state and there is no skill→plugin channel for one, so the halt is
|
|
65
|
+
* applied at the webapp, where proposals actually live. That is sufficient:
|
|
66
|
+
* the plugin's listener can only fire rows returned by /pending-decisions,
|
|
67
|
+
* which requires status='approved', and a cancelled row can never be approved.
|
|
68
|
+
*
|
|
69
|
+
* ★ FAIL-SOFT, NEVER FAIL-BLOCKING. This must not delay or abort a kill. Any
|
|
70
|
+
* error returns a *reported* failure rather than throwing — a halt that
|
|
71
|
+
* cancelled positions but couldn't confirm proposals is still a halt, and the
|
|
72
|
+
* operator needs to be told which half is uncertain rather than getting a
|
|
73
|
+
* clean green.
|
|
74
|
+
*
|
|
75
|
+
* ★ Proposals a listener already CLAIMED are not cancelled — a claim is taken
|
|
76
|
+
* immediately before order submission, so such a row may already be live at
|
|
77
|
+
* the exchange. We surface the count instead of asserting a cancellation we
|
|
78
|
+
* cannot guarantee.
|
|
79
|
+
*/
|
|
80
|
+
export declare function cancelPendingProposals(operatorToken: string | undefined, reason: 'kill_switch' | 'flatten' | 'pause' | 'mode_disabled', fetchImpl?: typeof fetch): Promise<ProposalCancelSummary>;
|
|
81
|
+
/** Fold a proposal-cancel summary into an EmergencyResult's details line. */
|
|
82
|
+
export declare function withProposalDetail(result: EmergencyResult, summary: ProposalCancelSummary): EmergencyResult;
|
|
47
83
|
/**
|
|
48
84
|
* Pause: prevent new entries, keep existing positions.
|
|
49
85
|
* Returns the new agent mode if the transition is valid.
|