@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.
- package/bridge/bridge.d.ts +28 -1
- package/bridge/bridge.js +145 -7
- package/bridge/utils/skill-version.d.ts +9 -0
- package/bridge/utils/skill-version.js +45 -1
- package/ccxt/binance-ban-gate.js +12 -1
- package/ccxt/binance-public.d.ts +15 -0
- package/ccxt/binance-public.js +34 -1
- package/config/agent-config-client.d.ts +22 -0
- package/config/agent-config-client.js +44 -1
- package/config/agent-config-poller.d.ts +8 -1
- package/config/agent-config-poller.js +1 -0
- package/config/entitlement-gate.d.ts +51 -0
- package/config/entitlement-gate.js +137 -0
- package/index.js +54 -23
- package/ingest/pending-entry-metadata.d.ts +31 -9
- package/ingest/pending-entry-metadata.js +70 -16
- package/ingest/position-auto-capture.js +14 -3
- package/ingest/readiness-reporter.d.ts +19 -0
- package/ingest/readiness-reporter.js +142 -0
- package/live/exchange-info-cache.d.ts +3 -1
- package/live/exchange-info-cache.js +17 -2
- package/package.json +1 -1
- package/signals/strategy-adapter.d.ts +35 -2
- package/signals/strategy-adapter.js +87 -10
- package/skills/reefclaw/SKILL.md +6 -0
- package/tools/create-order.js +26 -20
package/bridge/bridge.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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.
|
package/ccxt/binance-ban-gate.js
CHANGED
|
@@ -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;
|
package/ccxt/binance-public.d.ts
CHANGED
|
@@ -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
|
}
|
package/ccxt/binance-public.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
|
@@ -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;
|