@reefclaw/openclaw-plugin 0.1.1 → 0.1.3

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.
@@ -24,6 +24,16 @@ export declare class Bridge {
24
24
  private readonly listeners;
25
25
  /** rc_ connection token — also authenticates the webapp skill-content pull */
26
26
  private readonly connectionToken;
27
+ /** While the local SKILL.md is still a bootstrap (0.x), re-attempt the webapp
28
+ * pull on a fixed cadence so a box whose one-shot startup pull failed — a
29
+ * bad/rotated token during setup, a webapp blip, or an old connector build —
30
+ * still self-heals off the bootstrap even if it never reconnects. Cleared the
31
+ * moment the full trading SKILL.md (2.x) applies, and on stop(). */
32
+ private bootstrapHealTimer;
33
+ /** Guards against overlapping webapp pull chains (startup + reconnect + heal
34
+ * timer) double-applying / double-notifying during the one-time bootstrap→
35
+ * full transition. The chain that starts (attempt 1) owns the flag. */
36
+ private skillPullInFlight;
27
37
  constructor(provider: OpenClawProvider, connectorConfig: ConnectorConfig);
28
38
  /** Start the bridge: connect to relay + start provider */
29
39
  start(): void;
@@ -104,6 +114,23 @@ export declare class Bridge {
104
114
  * gate when enforced) as the relay pull path.
105
115
  */
106
116
  private pullSkillContentFromWebapp;
107
- /** Check relay's latest SKILL.md version against local and auto-update if stale */
117
+ /** Start a bounded periodic re-pull of the full SKILL.md while the box is
118
+ * still on the bootstrap (0.x). pullSkillContentFromWebapp() (fired once at
119
+ * startup) is a one-shot with a finite retry burst; if every attempt fails —
120
+ * rotated token during setup, webapp blip, or an old connector build — a box
121
+ * that then stays stably connected (no reconnect to re-trigger the pull)
122
+ * would remain stranded on the bootstrap forever. This interval re-attempts
123
+ * every few minutes until the full 2.x skill applies, then retires itself.
124
+ * Strict no-op once upgraded (guarded on version), so upgraded boxes add zero
125
+ * steady-state webapp load. */
126
+ private startBootstrapHeal;
127
+ /** Stop the bootstrap heal timer (on successful upgrade or bridge stop). */
128
+ private stopBootstrapHeal;
129
+ /** On every relay (re)connect: (1) if still on the bootstrap SKILL.md, re-fire
130
+ * the authenticated webapp pull — the relay's per-room store is empty for a
131
+ * fresh user, so the webapp endpoint is the only source of the first full
132
+ * copy and the one-shot in start() may have exhausted its retries during
133
+ * setup; (2) apply any newer version the relay advertises (the OTA push
134
+ * channel for updates once the store is populated). */
108
135
  private handleConnectAck;
109
136
  }
package/bridge/bridge.js CHANGED
@@ -6,7 +6,7 @@ import { join } from 'path';
6
6
  import { homedir } from 'os';
7
7
  import { logger } from './logger.js';
8
8
  import { Connector } from './connector.js';
9
- import { readLocalSkillVersion, validateSkillContent, compareSemver } from './utils/skill-version.js';
9
+ import { readLocalSkillVersion, readAgentVisibleSkillVersions, validateSkillContent, compareSemver } from './utils/skill-version.js';
10
10
  import { verifySkillSignature, signatureRequired, readLastAppliedSignedAtMs, recordAppliedSignedAt, } from './utils/skill-signing.js';
11
11
  import { isTradingMode, validateModeTransition, redactTokens } from '@reefclaw/shared';
12
12
  import { OPERATOR_WRITE_METHODS } from './types.js';
@@ -47,6 +47,19 @@ const CRITICAL_EVENTS = new Set([
47
47
  'skill_update_applied', // OTA SKILL.md confirmation
48
48
  // All chat events are on the 'chat' channel and always sent immediately
49
49
  ]);
50
+ /** How often to re-attempt the webapp SKILL.md pull while still on a bootstrap
51
+ * (0.x) version. Long enough to be negligible load, short enough to un-strand
52
+ * a new user whose one-shot startup pull failed. */
53
+ const BOOTSTRAP_SKILL_HEAL_INTERVAL_MS = 10 * 60_000; // 10 min
54
+ /** True while the agent is still on the thin bootstrap SKILL.md and has NOT yet
55
+ * received the full trading instructions. The bootstrap ships 0.0.x; the full
56
+ * trading SKILL.md is 2.x — treat anything below 1.0.0 (or an unknown version)
57
+ * as bootstrap so we keep pulling until the real skill lands. */
58
+ function isBootstrapSkillVersion(version) {
59
+ if (!version)
60
+ return true;
61
+ return compareSemver('1.0.0', version) > 0; // '1.0.0' > version ⟺ version < 1.0.0
62
+ }
50
63
  export class Bridge {
51
64
  provider;
52
65
  connector;
@@ -79,6 +92,16 @@ export class Bridge {
79
92
  listeners = [];
80
93
  /** rc_ connection token — also authenticates the webapp skill-content pull */
81
94
  connectionToken;
95
+ /** While the local SKILL.md is still a bootstrap (0.x), re-attempt the webapp
96
+ * pull on a fixed cadence so a box whose one-shot startup pull failed — a
97
+ * bad/rotated token during setup, a webapp blip, or an old connector build —
98
+ * still self-heals off the bootstrap even if it never reconnects. Cleared the
99
+ * moment the full trading SKILL.md (2.x) applies, and on stop(). */
100
+ bootstrapHealTimer = null;
101
+ /** Guards against overlapping webapp pull chains (startup + reconnect + heal
102
+ * timer) double-applying / double-notifying during the one-time bootstrap→
103
+ * full transition. The chain that starts (attempt 1) owns the flag. */
104
+ skillPullInFlight = false;
82
105
  constructor(provider, connectorConfig) {
83
106
  this.provider = provider;
84
107
  this.currentSkillVersion = readLocalSkillVersion();
@@ -102,11 +125,17 @@ export class Bridge {
102
125
  // in the public npm package. Fire-and-forget with retries; the relay OTA
103
126
  // path (handleConnectAck) remains the push channel for updates.
104
127
  void this.pullSkillContentFromWebapp();
128
+ // The one-shot above has a finite retry burst; this keeps re-trying every
129
+ // few minutes until the full SKILL.md lands, so a box that stays connected
130
+ // without reconnecting can't be stranded on the bootstrap. No-op once
131
+ // upgraded (guarded on version).
132
+ this.startBootstrapHeal();
105
133
  }
106
134
  /** Stop the bridge: disconnect + stop provider */
107
135
  stop() {
108
136
  logger.info(TAG, 'Stopping bridge');
109
137
  this.stopThrottleTimer();
138
+ this.stopBootstrapHeal();
110
139
  this.unwireProviderEvents();
111
140
  this.connector.destroy();
112
141
  this.provider.stop();
@@ -808,6 +837,37 @@ export class Bridge {
808
837
  logger.warn(TAG, `Failed to update workspace skill SKILL.md: ${scanErr instanceof Error ? scanErr.message : String(scanErr)}`);
809
838
  }
810
839
  }
840
+ // 2d. Also write to the plugin-extension bundled skill directory — the
841
+ // chat-channel install layout (`/plugins install clawhub:...`) loads the
842
+ // agent-visible skill from ~/.openclaw/extensions/<plugin>/skills/, and a
843
+ // plugin (re)install resets that copy to the bundled bootstrap while the
844
+ // workspace copy above (our version tracker) survives. Without this
845
+ // write the agent stays on the bootstrap forever while the pull reports
846
+ // "already up to date" (observed live 2026-07-11).
847
+ const extensionsDir = join(homedir(), '.openclaw', 'extensions');
848
+ if (existsSync(extensionsDir)) {
849
+ try {
850
+ for (const ext of readdirSync(extensionsDir, { withFileTypes: true })) {
851
+ if (!ext.isDirectory() || !ext.name.includes('reefclaw'))
852
+ continue;
853
+ const extSkillsDir = join(extensionsDir, ext.name, 'skills');
854
+ if (!existsSync(extSkillsDir))
855
+ continue;
856
+ for (const skillEntry of readdirSync(extSkillsDir, { withFileTypes: true })) {
857
+ if (!skillEntry.isDirectory() || !skillEntry.name.includes('reefclaw'))
858
+ continue;
859
+ const extSkillMd = join(extSkillsDir, skillEntry.name, 'SKILL.md');
860
+ if (existsSync(extSkillMd)) {
861
+ writeFileSync(extSkillMd, content, 'utf-8');
862
+ logger.info(TAG, `Updated extension skill SKILL.md: ${extSkillMd}`);
863
+ }
864
+ }
865
+ }
866
+ }
867
+ catch (scanErr) {
868
+ logger.warn(TAG, `Failed to update extension skill SKILL.md: ${scanErr instanceof Error ? scanErr.message : String(scanErr)}`);
869
+ }
870
+ }
811
871
  // 3. Invalidate skillsSnapshot cache WITHOUT wiping sessions.json
812
872
  // Previously we wrote '{}' to sessions.json which destroyed OpenClaw chat history.
813
873
  // Now we surgically remove only the skillsSnapshot key from each session entry,
@@ -916,6 +976,17 @@ export class Bridge {
916
976
  * gate when enforced) as the relay pull path.
917
977
  */
918
978
  async pullSkillContentFromWebapp(attempt = 1) {
979
+ // Only one pull chain at a time: startup, reconnect (handleConnectAck) and
980
+ // the bootstrap heal timer can each trigger a pull. Without this guard they
981
+ // could overlap during the one-time bootstrap→full transition and
982
+ // double-apply / double-notify the agent. The chain that starts (attempt 1)
983
+ // owns the flag; whichever attempt terminates the chain clears it. A retry
984
+ // (attempt > 1) is the same chain continuing, so it must NOT re-check/re-set.
985
+ if (attempt === 1) {
986
+ if (this.skillPullInFlight)
987
+ return;
988
+ this.skillPullInFlight = true;
989
+ }
919
990
  const MAX_ATTEMPTS = 5;
920
991
  // www is load-bearing: reefclaw.com 307-redirects and Node fetch strips the
921
992
  // Authorization header on cross-origin redirect (verified 2026-03-18).
@@ -927,21 +998,36 @@ export class Bridge {
927
998
  });
928
999
  if (res.status === 401 || res.status === 403) {
929
1000
  logger.warn(TAG, `SKILL.md webapp pull: not authorized (${res.status}) — connect the account first`);
930
- return; // A bad token won't get better by retrying.
1001
+ this.skillPullInFlight = false;
1002
+ return; // A bad token won't get better by retrying within this chain.
931
1003
  }
932
1004
  if (!res.ok)
933
1005
  throw new Error(`HTTP ${res.status}`);
934
1006
  const body = (await res.json());
935
1007
  if (!body?.version || !body?.content) {
936
1008
  logger.warn(TAG, 'SKILL.md webapp pull: response missing version/content');
1009
+ this.skillPullInFlight = false;
937
1010
  return;
938
1011
  }
939
1012
  this.currentSkillVersion = readLocalSkillVersion();
940
- if (compareSemver(body.version, this.currentSkillVersion) <= 0) {
1013
+ // The workspace tracker alone is NOT proof the agent has the full skill:
1014
+ // a plugin (re)install resets the agent-visible extension copy to the
1015
+ // bundled bootstrap while the workspace copy survives (observed live
1016
+ // 2026-07-11 — agent stranded on 0.0.5 with the tracker at 2.20.4). If
1017
+ // ANY agent-visible copy is behind the webapp version, re-apply anyway;
1018
+ // applySkillUpdate is idempotent across all copies.
1019
+ const staleAgentCopies = readAgentVisibleSkillVersions().filter((v) => compareSemver(body.version, v) > 0);
1020
+ if (compareSemver(body.version, this.currentSkillVersion) <= 0 && staleAgentCopies.length === 0) {
941
1021
  logger.info(TAG, `SKILL.md webapp pull: local v${this.currentSkillVersion ?? 'unknown'} already >= webapp v${body.version} — nothing to do`);
1022
+ this.skillPullInFlight = false;
942
1023
  return;
943
1024
  }
944
- logger.info(TAG, `SKILL.md webapp pull: local v${this.currentSkillVersion ?? 'unknown'} behind webapp v${body.version} — applying`);
1025
+ if (staleAgentCopies.length > 0 && compareSemver(body.version, this.currentSkillVersion) <= 0) {
1026
+ logger.info(TAG, `SKILL.md webapp pull: agent-visible skill copy at v${staleAgentCopies[0]} behind webapp v${body.version} (plugin reinstall reset it) — re-applying`);
1027
+ }
1028
+ else {
1029
+ logger.info(TAG, `SKILL.md webapp pull: local v${this.currentSkillVersion ?? 'unknown'} behind webapp v${body.version} — applying`);
1030
+ }
945
1031
  const result = await this.applySkillUpdate({ version: body.version, content: body.content });
946
1032
  this.emit('agent_state', 'skill_update_applied', {
947
1033
  event: 'skill_update_applied',
@@ -952,25 +1038,77 @@ export class Bridge {
952
1038
  });
953
1039
  if (!result.success)
954
1040
  logger.error(TAG, `SKILL.md webapp pull: apply failed: ${result.message}`);
1041
+ this.skillPullInFlight = false;
1042
+ // A successful apply moved us off the bootstrap — retire the heal timer.
1043
+ if (result.success)
1044
+ this.stopBootstrapHeal();
955
1045
  }
956
1046
  catch (err) {
957
1047
  const msg = err instanceof Error ? err.message : String(err);
958
1048
  if (attempt >= MAX_ATTEMPTS) {
959
1049
  logger.error(TAG, `SKILL.md webapp pull failed after ${MAX_ATTEMPTS} attempts: ${msg}`);
1050
+ this.skillPullInFlight = false;
960
1051
  return;
961
1052
  }
962
1053
  const delayMs = attempt * 30_000;
963
1054
  logger.warn(TAG, `SKILL.md webapp pull failed (attempt ${attempt}/${MAX_ATTEMPTS}): ${msg} — retrying in ${delayMs / 1000}s`);
1055
+ // Keep skillPullInFlight = true: the chain continues on the scheduled retry.
964
1056
  setTimeout(() => void this.pullSkillContentFromWebapp(attempt + 1), delayMs).unref?.();
965
1057
  }
966
1058
  }
967
- /** Check relay's latest SKILL.md version against local and auto-update if stale */
1059
+ /** Start a bounded periodic re-pull of the full SKILL.md while the box is
1060
+ * still on the bootstrap (0.x). pullSkillContentFromWebapp() (fired once at
1061
+ * startup) is a one-shot with a finite retry burst; if every attempt fails —
1062
+ * rotated token during setup, webapp blip, or an old connector build — a box
1063
+ * that then stays stably connected (no reconnect to re-trigger the pull)
1064
+ * would remain stranded on the bootstrap forever. This interval re-attempts
1065
+ * every few minutes until the full 2.x skill applies, then retires itself.
1066
+ * Strict no-op once upgraded (guarded on version), so upgraded boxes add zero
1067
+ * steady-state webapp load. */
1068
+ startBootstrapHeal() {
1069
+ if (this.bootstrapHealTimer)
1070
+ return;
1071
+ if (!isBootstrapSkillVersion(this.currentSkillVersion))
1072
+ return; // already on the full skill
1073
+ this.bootstrapHealTimer = setInterval(() => {
1074
+ // Re-read from disk: the file may have been upgraded out-of-band (relay
1075
+ // OTA push, manual SFTP) since we last cached it.
1076
+ this.currentSkillVersion = readLocalSkillVersion();
1077
+ if (!isBootstrapSkillVersion(this.currentSkillVersion)) {
1078
+ this.stopBootstrapHeal();
1079
+ return;
1080
+ }
1081
+ logger.info(TAG, `Still on bootstrap SKILL.md (v${this.currentSkillVersion ?? 'unknown'}) — re-attempting webapp pull`);
1082
+ void this.pullSkillContentFromWebapp();
1083
+ }, BOOTSTRAP_SKILL_HEAL_INTERVAL_MS);
1084
+ this.bootstrapHealTimer.unref?.();
1085
+ }
1086
+ /** Stop the bootstrap heal timer (on successful upgrade or bridge stop). */
1087
+ stopBootstrapHeal() {
1088
+ if (this.bootstrapHealTimer) {
1089
+ clearInterval(this.bootstrapHealTimer);
1090
+ this.bootstrapHealTimer = null;
1091
+ }
1092
+ }
1093
+ /** On every relay (re)connect: (1) if still on the bootstrap SKILL.md, re-fire
1094
+ * the authenticated webapp pull — the relay's per-room store is empty for a
1095
+ * fresh user, so the webapp endpoint is the only source of the first full
1096
+ * copy and the one-shot in start() may have exhausted its retries during
1097
+ * setup; (2) apply any newer version the relay advertises (the OTA push
1098
+ * channel for updates once the store is populated). */
968
1099
  async handleConnectAck(payload) {
1100
+ // Re-read local version (may have changed since startup)
1101
+ this.currentSkillVersion = readLocalSkillVersion();
1102
+ // Fresh-user self-heal: while still on the thin bootstrap SKILL.md, pull the
1103
+ // full instructions from the webapp on this (re)connect. Guarded (in-flight)
1104
+ // + downgrade-checked, so it's a strict no-op once upgraded.
1105
+ if (isBootstrapSkillVersion(this.currentSkillVersion)) {
1106
+ logger.info(TAG, `Connect ack while on bootstrap SKILL.md (v${this.currentSkillVersion ?? 'unknown'}) — pulling full instructions from webapp`);
1107
+ void this.pullSkillContentFromWebapp();
1108
+ }
969
1109
  const latestVersion = payload.latestSkillVersion;
970
1110
  if (!latestVersion)
971
1111
  return;
972
- // Re-read local version (may have changed since startup)
973
- this.currentSkillVersion = readLocalSkillVersion();
974
1112
  const cmp = compareSemver(latestVersion, this.currentSkillVersion);
975
1113
  if (cmp === 0) {
976
1114
  logger.debug(TAG, `SKILL.md up to date (v${latestVersion})`);
@@ -2,6 +2,15 @@
2
2
  export declare function parseSkillVersion(content: string): string | null;
3
3
  /** Read current SKILL.md version from disk */
4
4
  export declare function readLocalSkillVersion(): string | null;
5
+ /** Versions of the AGENT-VISIBLE reefclaw SKILL.md copies under the
6
+ * plugin-extension layout (`~/.openclaw/extensions/<plugin>/skills/<skill>/`)
7
+ * — what OpenClaw's skill loader actually reads on chat-channel installs.
8
+ * A plugin (re)install resets these to the bundled bootstrap while the
9
+ * workspace copy (the version tracker above) survives, so the tracker alone
10
+ * cannot be trusted for "already up to date" (observed live 2026-07-11: agent
11
+ * stranded on bootstrap 0.0.5 while the tracker read 2.20.4). Fail-soft:
12
+ * unreadable dirs/files are skipped; no extensions dir → empty list. */
13
+ export declare function readAgentVisibleSkillVersions(): string[];
5
14
  /**
6
15
  * Compare two semver strings.
7
16
  * Returns 1 if a > b, -1 if a < b, 0 if equal.
@@ -1,5 +1,5 @@
1
1
  // SKILL.md version parsing utility for OTA updates
2
- import { readFileSync } from 'fs';
2
+ import { readFileSync, readdirSync } from 'fs';
3
3
  import { join } from 'path';
4
4
  import { homedir } from 'os';
5
5
  const SKILL_MD_PATH = join(homedir(), '.openclaw', 'workspace', 'SKILL.md');
@@ -18,6 +18,50 @@ export function readLocalSkillVersion() {
18
18
  return null;
19
19
  }
20
20
  }
21
+ /** Versions of the AGENT-VISIBLE reefclaw SKILL.md copies under the
22
+ * plugin-extension layout (`~/.openclaw/extensions/<plugin>/skills/<skill>/`)
23
+ * — what OpenClaw's skill loader actually reads on chat-channel installs.
24
+ * A plugin (re)install resets these to the bundled bootstrap while the
25
+ * workspace copy (the version tracker above) survives, so the tracker alone
26
+ * cannot be trusted for "already up to date" (observed live 2026-07-11: agent
27
+ * stranded on bootstrap 0.0.5 while the tracker read 2.20.4). Fail-soft:
28
+ * unreadable dirs/files are skipped; no extensions dir → empty list. */
29
+ export function readAgentVisibleSkillVersions() {
30
+ const versions = [];
31
+ const extensionsDir = join(homedir(), '.openclaw', 'extensions');
32
+ let extEntries;
33
+ try {
34
+ extEntries = readdirSync(extensionsDir, { withFileTypes: true });
35
+ }
36
+ catch {
37
+ return versions;
38
+ }
39
+ for (const ext of extEntries) {
40
+ if (!ext.isDirectory() || !ext.name.includes('reefclaw'))
41
+ continue;
42
+ const skillsDir = join(extensionsDir, ext.name, 'skills');
43
+ let skillEntries;
44
+ try {
45
+ skillEntries = readdirSync(skillsDir, { withFileTypes: true });
46
+ }
47
+ catch {
48
+ continue;
49
+ }
50
+ for (const s of skillEntries) {
51
+ if (!s.isDirectory() || !s.name.includes('reefclaw'))
52
+ continue;
53
+ try {
54
+ const v = parseSkillVersion(readFileSync(join(skillsDir, s.name, 'SKILL.md'), 'utf-8'));
55
+ if (v)
56
+ versions.push(v);
57
+ }
58
+ catch {
59
+ // unreadable/absent copy — nothing to report for this entry
60
+ }
61
+ }
62
+ }
63
+ return versions;
64
+ }
21
65
  // 120KB — the full SKILL.md crossed 100KB at v2.20.0 (103.7KB), which silently
22
66
  // broke every OTA apply against the old 100KB cap. Keep comfortably under the
23
67
  // 128KiB Cloudflare Durable-Object per-value hard limit the relay stores into.
@@ -128,6 +128,12 @@ const CONTEXT_WEIGHT = {
128
128
  fetchOHLCV: 2,
129
129
  fetchFundingRate: 1,
130
130
  fetchOpenInterest: 1,
131
+ // Readiness gate's host→Binance reachability probe (fapiPublicGetTime, wt 1).
132
+ reachabilityProbe: 1,
133
+ // ExchangeInfoCache boot load — ccxt loadMarkets() → GET /fapi/v1/exchangeInfo,
134
+ // IP weight 1 (doc-verified developers.binance.com 2026-07-10). Was the
135
+ // known "ungated_ip" ccxt-internal call named in the window-summary comment.
136
+ loadMarkets: 1,
131
137
  fetchBalance: 5,
132
138
  fetchPositions: 5,
133
139
  // BinancePrivateApi.fetchOpenOrders merges TWO REST calls (regular
@@ -175,6 +181,11 @@ const NEVER_PACE = new Set([
175
181
  'createOrder', 'cancelOrder', 'cancelAllOrders', 'cancelOrderByClientId',
176
182
  'createBracketOrder', 'createListenKey', 'keepAliveListenKey', 'closeListenKey',
177
183
  'fetchTodayIncomeBreakdownForced',
184
+ // ExchangeInfoCache boot load: runs once per adapter init and a failure
185
+ // BLOCKS the adapter (no retry until restart), so a paced-out load would
186
+ // trade ~1 weight of relief for a dead adapter. Ban-gated + counted like
187
+ // the listenKey contexts, never shed.
188
+ 'loadMarkets',
178
189
  ]);
179
190
  /** Low-priority contexts that shed FIRST under weight pressure (paced
180
191
  * against COSMETIC_SOFT_CEILING, not the full ceiling). Two safe-to-shed
@@ -191,7 +202,7 @@ const NEVER_PACE = new Set([
191
202
  const SHED_FIRST = new Set([
192
203
  'fetchTodayIncomeBreakdown', 'fetchTransfers', 'fetchRecentTradedSymbols',
193
204
  'fetchTicker', 'fetchTickerRaw', 'fetchOHLCV', 'fetchOrderBook',
194
- 'fetchFundingRate', 'fetchOpenInterest',
205
+ 'fetchFundingRate', 'fetchOpenInterest', 'reachabilityProbe',
195
206
  ]);
196
207
  let weightWindowStart = 0;
197
208
  let weightUsed = 0;
@@ -15,4 +15,19 @@ export declare class BinancePublicApi {
15
15
  fetchOrderBook(symbol: string, limit?: number): Promise<OrderBookDepth | null>;
16
16
  /** Fetch OHLCV candles. Returns null on error. */
17
17
  fetchOHLCV(symbol: string, timeframe?: string, limit?: number): Promise<CcxtOHLCV[] | null>;
18
+ /** Probe Binance USD-M FUTURES reachability from this host — the readiness
19
+ * gate's core signal. Calls the futures-explicit implicit method so it hits
20
+ * `fapi.binance.com` (a bare `fetchTime()` on this instance resolves to spot
21
+ * `api.binance.com`, since the public client doesn't set defaultType:'future').
22
+ * HTTP 451 = Binance geo-restriction; the ban gate does NOT classify 451, so
23
+ * we inspect the message here. Ban-gate compliant (assertNotBanned/noteSuccess/
24
+ * noteBinanceError). Outcomes:
25
+ * - 'reachable' clean response (driftMs = serverTime − localTime)
26
+ * - 'geo_blocked' HTTP 451 — host is in a restricted region (actionable)
27
+ * - 'unknown' the ban/weight gate paused us — NOT a host problem
28
+ * - 'unreachable' network / DNS / timeout / other error */
29
+ probeReachability(): Promise<{
30
+ outcome: 'reachable' | 'geo_blocked' | 'unreachable' | 'unknown';
31
+ driftMs: number | null;
32
+ }>;
18
33
  }
@@ -2,7 +2,7 @@
2
2
  // No API keys required — only uses public market data.
3
3
  import { createRequire } from 'node:module';
4
4
  import { logger } from '../logger.js';
5
- import { assertNotBanned, noteBinanceError, noteSuccess } from './binance-ban-gate.js';
5
+ import { assertNotBanned, noteBinanceError, noteSuccess, BinanceBannedError } from './binance-ban-gate.js';
6
6
  const TAG = 'binance-public';
7
7
  // Load ccxt via CJS require — OpenClaw's ESM loader gives wrong module shape
8
8
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -144,4 +144,37 @@ export class BinancePublicApi {
144
144
  return null;
145
145
  }
146
146
  }
147
+ /** Probe Binance USD-M FUTURES reachability from this host — the readiness
148
+ * gate's core signal. Calls the futures-explicit implicit method so it hits
149
+ * `fapi.binance.com` (a bare `fetchTime()` on this instance resolves to spot
150
+ * `api.binance.com`, since the public client doesn't set defaultType:'future').
151
+ * HTTP 451 = Binance geo-restriction; the ban gate does NOT classify 451, so
152
+ * we inspect the message here. Ban-gate compliant (assertNotBanned/noteSuccess/
153
+ * noteBinanceError). Outcomes:
154
+ * - 'reachable' clean response (driftMs = serverTime − localTime)
155
+ * - 'geo_blocked' HTTP 451 — host is in a restricted region (actionable)
156
+ * - 'unknown' the ban/weight gate paused us — NOT a host problem
157
+ * - 'unreachable' network / DNS / timeout / other error */
158
+ async probeReachability() {
159
+ try {
160
+ assertNotBanned('reachabilityProbe');
161
+ const r = await this.exchange.fapiPublicGetTime({});
162
+ noteSuccess();
163
+ const serverTime = Number(r?.serverTime);
164
+ const driftMs = Number.isFinite(serverTime) ? serverTime - Date.now() : null;
165
+ return { outcome: 'reachable', driftMs };
166
+ }
167
+ catch (err) {
168
+ noteBinanceError(err);
169
+ if (err instanceof BinanceBannedError) {
170
+ // Gate paused us (418/429 backoff or weight pacer) — we didn't actually
171
+ // reach the host, so the result is UNKNOWN, not a reachability failure.
172
+ return { outcome: 'unknown', driftMs: null };
173
+ }
174
+ const msg = err instanceof Error ? err.message : String(err);
175
+ const geo = msg.includes('451');
176
+ logger.warn(TAG, `probeReachability failed${geo ? ' (HTTP 451 geo-block)' : ''}: ${msg}`);
177
+ return { outcome: geo ? 'geo_blocked' : 'unreachable', driftMs: null };
178
+ }
179
+ }
147
180
  }
@@ -10,6 +10,19 @@ export interface AgentGates {
10
10
  exitGate?: 'off' | 'shadow' | 'observe' | 'enforce';
11
11
  positionReviewMode?: 'off' | 'shadow' | 'observe' | 'enforce';
12
12
  }
13
+ /** Server-resolved entitlement verdict (webapp lib/entitlements.ts, computed
14
+ * from the users row and delivered on the config channel). The plugin NEVER
15
+ * re-derives billing state — it consumes `paidAccess` as-is. Absent field =
16
+ * unknown = the entitlement gate fails OPEN (older webapp, outage, garbage):
17
+ * billing enforcement must never depend on this read succeeding; the
18
+ * server-side 403 walls (webapp Pro routes + intel) are the hard layer. */
19
+ export interface AgentEntitlement {
20
+ state: 'active' | 'trialing' | 'grace' | 'expired' | 'none';
21
+ paidAccess: boolean;
22
+ tier?: string;
23
+ expiresAt?: string;
24
+ graceUntil?: string;
25
+ }
13
26
  /** The slice of /api/internal/config the plugin consumes today. `limits`
14
27
  * arrives in the payload but is deliberately not modeled yet. */
15
28
  export interface AgentConfig {
@@ -18,6 +31,8 @@ export interface AgentConfig {
18
31
  disabled: string[];
19
32
  };
20
33
  gates: AgentGates;
34
+ /** Absent when the server omitted it or the field failed validation. */
35
+ entitlement?: AgentEntitlement;
21
36
  }
22
37
  /** Hardcoded safe default — identical to pre-central behavior (nothing
23
38
  * disabled, no central gate values → local file / defaults rule). */
@@ -29,6 +44,13 @@ export declare function defaultAgentConfig(): AgentConfig;
29
44
  * the plugin doesn't already consider safe).
30
45
  */
31
46
  export declare function validateAgentConfig(raw: unknown): AgentConfig | null;
47
+ /** Per-field entitlement validation: a structurally-valid `{state, paidAccess}`
48
+ * pair (state in the enum, paidAccess a real boolean) is kept; ANYTHING else
49
+ * → undefined, and an absent entitlement makes the gate fail open. Garbage
50
+ * can pause new entries only by forging a syntactically-perfect verdict —
51
+ * and even then it can never touch closes/stops/emergency (those aren't in
52
+ * the gated set at all — see entitlement-gate.ts). */
53
+ export declare function validateEntitlement(raw: unknown): AgentEntitlement | undefined;
32
54
  /** Version-monotonic acceptance (basic rollback/replay protection): a fetched
33
55
  * config older than what we already applied is rejected. Equal versions are
34
56
  * acceptable (idempotent re-apply is a no-op for the gate). */
@@ -23,6 +23,13 @@ const TAG = 'agent-config';
23
23
  * because exitGate + positionReviewMode use identical values; a future gate
24
24
  * with a different enum gets its own constant. */
25
25
  const MODE_LADDER_VALUES = new Set(['off', 'shadow', 'observe', 'enforce']);
26
+ const ENTITLEMENT_STATES = new Set([
27
+ 'active',
28
+ 'trialing',
29
+ 'grace',
30
+ 'expired',
31
+ 'none',
32
+ ]);
26
33
  /** Hardcoded safe default — identical to pre-central behavior (nothing
27
34
  * disabled, no central gate values → local file / defaults rule). */
28
35
  export function defaultAgentConfig() {
@@ -51,7 +58,43 @@ export function validateAgentConfig(raw) {
51
58
  if (!Array.isArray(disabledRaw))
52
59
  return null;
53
60
  const disabled = disabledRaw.filter((v) => typeof v === 'string' && TOOL_NAME_RE.test(v));
54
- return { version, tools: { disabled }, gates: validateGates(obj.gates) };
61
+ const config = {
62
+ version,
63
+ tools: { disabled },
64
+ gates: validateGates(obj.gates),
65
+ };
66
+ const entitlement = validateEntitlement(obj.entitlement);
67
+ if (entitlement)
68
+ config.entitlement = entitlement;
69
+ return config;
70
+ }
71
+ /** Per-field entitlement validation: a structurally-valid `{state, paidAccess}`
72
+ * pair (state in the enum, paidAccess a real boolean) is kept; ANYTHING else
73
+ * → undefined, and an absent entitlement makes the gate fail open. Garbage
74
+ * can pause new entries only by forging a syntactically-perfect verdict —
75
+ * and even then it can never touch closes/stops/emergency (those aren't in
76
+ * the gated set at all — see entitlement-gate.ts). */
77
+ export function validateEntitlement(raw) {
78
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
79
+ return undefined;
80
+ const obj = raw;
81
+ const state = obj.state;
82
+ const paidAccess = obj.paidAccess;
83
+ if (typeof state !== 'string' || !ENTITLEMENT_STATES.has(state))
84
+ return undefined;
85
+ if (typeof paidAccess !== 'boolean')
86
+ return undefined;
87
+ const entitlement = {
88
+ state: state,
89
+ paidAccess,
90
+ };
91
+ if (typeof obj.tier === 'string')
92
+ entitlement.tier = obj.tier;
93
+ if (typeof obj.expiresAt === 'string')
94
+ entitlement.expiresAt = obj.expiresAt;
95
+ if (typeof obj.graceUntil === 'string')
96
+ entitlement.graceUntil = obj.graceUntil;
97
+ return entitlement;
55
98
  }
56
99
  /** Per-field gate validation (§5b): a structurally-absent/garbage `gates`
57
100
  * becomes `{}` (no central values → local config rules); each known gate is
@@ -1,4 +1,4 @@
1
- import { type AgentConfig, type AgentGates } from './agent-config-client.js';
1
+ import { type AgentConfig, type AgentEntitlement, type AgentGates } from './agent-config-client.js';
2
2
  import type { ToolGate } from './tool-gate.js';
3
3
  export declare function resolvePollIntervalMs(): number;
4
4
  export interface AgentConfigPollerOptions {
@@ -9,6 +9,13 @@ export interface AgentConfigPollerOptions {
9
9
  gateStore?: {
10
10
  apply(gates: AgentGates): void;
11
11
  };
12
+ /** Entitlement consumer (account-level enforcement). Optional, wired from
13
+ * index.ts with the module singleton `entitlementGate`. Receives the
14
+ * config's entitlement slice — including `undefined` when the server
15
+ * omitted it, which clears any held verdict (fail-open). */
16
+ entitlementGate?: {
17
+ apply(entitlement: AgentEntitlement | undefined): void;
18
+ };
12
19
  apiBaseUrl: string;
13
20
  token: string;
14
21
  fetchImpl?: typeof fetch;
@@ -54,6 +54,7 @@ export function startAgentConfigPoller(opts) {
54
54
  current = fetched;
55
55
  opts.gate.apply(fetched);
56
56
  opts.gateStore?.apply(fetched.gates);
57
+ opts.entitlementGate?.apply(fetched.entitlement);
57
58
  return true;
58
59
  };
59
60
  const poll = async (isBoot) => {
@@ -0,0 +1,51 @@
1
+ import type { AgentEntitlement } from './agent-config-client.js';
2
+ /** The ONLY tools an expired entitlement may pause. Deliberately a denylist
3
+ * (not "everything except safety"): a tool added tomorrow is un-gated by
4
+ * default, which is the conservative failure mode in a trading product.
5
+ * Intel/data tools are NOT here — the intelligence service and webapp 403
6
+ * those server-side with the same actionable message. */
7
+ export declare const ENTITLEMENT_GATED_TOOLS: ReadonlySet<string>;
8
+ export type EntitlementGateMode = 'enforce' | 'shadow' | 'off';
9
+ export declare function resolveEntitlementGateMode(): EntitlementGateMode;
10
+ /** Structured, agent-visible result for a blocked call. Worded so the agent
11
+ * stops cleanly (no retry loop), keeps managing the existing book, and tells
12
+ * the operator exactly how to restore service. */
13
+ export declare function entitlementBlockedResult(name: string): {
14
+ ok: false;
15
+ error: 'entitlement_expired';
16
+ tool: string;
17
+ message: string;
18
+ };
19
+ /** Minimal structural view of a registered plugin tool — same shape as
20
+ * tool-gate.ts GateablePluginTool (kept local to avoid a cross-module type
21
+ * dependency for one interface). */
22
+ export interface EntitlementGateableTool {
23
+ name: string;
24
+ execute: (...args: any[]) => any;
25
+ }
26
+ export declare class EntitlementGate {
27
+ private readonly mode;
28
+ private entitlement;
29
+ /** Avoids log spam: one line per state transition, not per blocked call. */
30
+ private lastLoggedKey;
31
+ constructor(mode?: EntitlementGateMode);
32
+ getMode(): EntitlementGateMode;
33
+ getEntitlement(): AgentEntitlement | undefined;
34
+ /** Apply the entitlement slice of a validated config (poller path).
35
+ * `undefined` (server omitted it / validation dropped it) CLEARS the held
36
+ * verdict — the gate then fails open rather than enforcing a stale one
37
+ * the server no longer asserts. */
38
+ apply(entitlement: AgentEntitlement | undefined): void;
39
+ /** Should this call be blocked? Non-gated names short-circuit FIRST (the
40
+ * safety set can never be touched, in any mode, with any payload); then
41
+ * only an explicit server verdict of paidAccess=false blocks. */
42
+ isBlocked(name: string): boolean;
43
+ /** Wrap a tool's execute with the gate check — same hot-apply pattern as
44
+ * ToolGate.wrapTool (state is consulted per call, not at registration). */
45
+ wrapTool<T extends EntitlementGateableTool>(tool: T, jsonResult: (data: unknown) => unknown): T;
46
+ /** Test-only. */
47
+ __reset(): void;
48
+ }
49
+ /** Module singleton — one gate per plugin process, shared by the poller
50
+ * (writer) and the tool-wrap in index.ts (consumer), mirroring gateStore. */
51
+ export declare const entitlementGate: EntitlementGate;